Skip to content

Instantly share code, notes, and snippets.

What would you like to do?
Chicago Ward Remap Outlines

Created by Christopher Manning


The wards in Chicago were recently remapped and I was mesmerized by the idea of creating an interaction that would animate the transition from the old to the new wards. I shortly found out that tweening polygons in a non-intersecting and interlocked fashion is a complicated topic. I've done a lot of reading about the math and research that has been done in this space and found a few interesting theories which I would like to implement in a future version. Currently, the morphing/tweening/interpolation is done with an array interpolator. Unfortunately, this technique causes the intermediate polygons to self-intersect and morph inefficiently. Ideally, I would overlay these polygons on a slippy map and there would be no gaps between the polygons during the morphing.

Overall, I am happy with the way this prototype came out and how it highlights the need for more robust polygon morphing. I am excited to see what the map transition will look like when a more fluid animation is implemented.


  • Drag the slider to morph the wards
  • Click and drag to move the map
  • Use the mousewheel to zoom
  • Click on a ward to change the shading
  • Click the Chicago star to change the layout
  • Hover over the ward to see its number and to highlight the ward
  • Click one set of the years to animate the transition between the wards
  • Start in map view

Points of Interest

  • The 2nd ward travels away from where it once was and now encompasses an entirely different area.
  • The morph of the 9th ward demonstrates why using a shortest path vertex transformation is misleading.
  • There is a hole in the 2005 19th ward that will be removed in 2015.
  • The morph of the 19th ward is comically inefficient and demonstrates how inadequate a naive interpolater is.
  • The 41st ward(O'Hare) is huge. It dwarfs the 44th ward which I previously thought was much bigger.
  • When you click the star to switch to the map view, click a ward to change the ward color to black, and zoom in; you can see how the polygon simplification causes small gaps between the wards where more vertices would have been required to make the edges seamless.
  • Every ward's boundary changed.


  • The GeoJSON files were simplified with ogr2ogr using a tolerance of .001 to improve rendering performance
  • The 19th and 41st wards were reduced from a MultiPolygon to a Polygon to make morphing work with the array interpolator


View this gist at

<html lang="en">
<title>Chicago Ward Remap Outlines</title>
<script src="//"></script>
<script type="text/javascript" src=""></script>
<script type="text/javascript" src=""></script>
<style type="text/css">
body {
color: #333;
.years, #star {
cursor: pointer
#wards path {
stroke-width: 0.5;
.ward-outline {
fill: white;
stroke: black;
.ward-fill {
fill: black;
stroke: white;
<div id="vis"></div>
<script type="text/javascript">
$(function() {
function getParameterByName(name) {
name = name.replace(/[\[]/, "\\\[").replace(/[\]]/, "\\\]");
var regexS = "[\\?&]" + name + "=([^&#]*)";
var regex = new RegExp(regexS);
var results = regex.exec(parent.window.location.href);
if(results == null)
return "";
return decodeURIComponent(results[1].replace(/\+/g, " "));
var alignToGrid = getParameterByName('map') == 1 ? false : true
var geometryCache = []
var projection_scale = 199466
var projection_translate = [49047.505335748254, 25762.791692685558]
var w = 960
var h = 425
var proj = d3.geo.mercator().scale(projection_scale).translate(projection_translate)
var path = d3.geo.path().projection(proj)
var t = proj.translate()
var s = proj.scale()
var wards_json = []
var map ="#vis").append("svg:svg").attr("width", w).attr("height", h).call(d3.behavior.zoom().scaleExtent([1, 8]).on("zoom", drawWards))
var wards = map.append("svg:g").attr("id", "wards")
var originalBBoxes = []
d3.json("wards_2005.json", function(json) {
wards_json.push([json.features.filter(function(d) {
return parseInt(
}).sort(function(a, b) {
return -
d3.json("wards_2015.json", function(json) {
var wardIndex = 0
wards_json.push([ {
//pad the arrays so they have the same number of vertices so morphing doesn't create artifacts
prevCoordinates = wards_json[0][0][wardIndex++].geometry.coordinates[0]
coordinates = d.geometry.coordinates[0]
for (i = prevCoordinates.length; i < coordinates.length; ++i) prevCoordinates.push(prevCoordinates[0])
for (i = coordinates.length; i < prevCoordinates.length; ++i) coordinates.push(coordinates[0])
return {'type': 'Feature', 'geometry': {'type': "Polygon", 'coordinates': [coordinates]}, 'properties':}
}).sort(function(a, b) {
return -
dataWards = wards.selectAll("path").data(wards_json[0][0], function(d) {
return parseInt(
function transformWard(d, i) {
if (!alignToGrid) return
//there isn't an easy way to absolutely position a path in a SVG
x = ( - 1) % 10
y = Math.floor(( - 1) / 10)
xOffset = 50 / 2 - this.getBBox().width / 2
xOffset = xOffset < 6 ? 6 : xOffset
yOffset = 50 / 2 - this.getBBox().height / 2
yOffset = yOffset < 1 ? 36 : yOffset + 36
//need to know where we originally positioned it so we can move map relative to that original position
if(originalBBoxes[i] == null) originalBBoxes[i] = this.getBBox()
//calculations are from the top left i.e. 0,0
return "translate(" + (-originalBBoxes[i].x + xOffset + (proj.scale()/2000 * x)) + ", " + (-originalBBoxes[i].y + yOffset + (proj.scale()/2500 * y)) + ")"
function drawWards() {
if (d3.event != null) {
proj.translate([t[0] * d3.event.scale + d3.event.translate[0], t[1] * d3.event.scale + d3.event.translate[1]])
proj.scale(s * d3.event.scale)
//so wards aren't added when the map moves
if($("#wards path").length == 0) {
dataWards.enter().append("svg:path").attr("class","ward-outline").attr("d", function(d) {
return path(d.geometry)
}).attr("transform", transformWard).append("svg:title").text(function(d, i) {
wards.selectAll("path").attr("d", function(d, i) {
return path(geometryCache[i] == null ? d.geometry : geometryCache[i])
}).attr("transform", transformWard).on("mouseover", function(){
if($(this).attr("class") == "ward-outline"){
$(this).attr("class", "ward-fill")
} else {
$(this).attr("class", "ward-outline")
}).on("mouseout", function(){
if($(this).attr("class") == "ward-outline"){
$(this).attr("class", "ward-fill")
} else {
$(this).attr("class", "ward-outline")
}).on("click", function(){
if($(this).attr("class") == "ward-outline"){
$("#wards path").attr("class", "ward-outline")
$(this).attr("class", "ward-fill")
} else {
$("#wards path").attr("class", "ward-fill")
$(this).attr("class", "ward-outline")
$("#star").toggle(function() {
alignToGrid = false
}, function() {
alignToGrid = true
$("#morphs").change(function() {
val = $(this).val()
wards.selectAll("path").attr("d", function(d, i) {
//so the shape is maintained when scaling/translating the map
geometryCache[i] = {'type': "Polygon", 'coordinates': [d3.interpolate(wards_json[0][0][ - 1].geometry.coordinates[0], wards_json[1][0][ - 1].geometry.coordinates[0])(val)]}
return path(geometryCache[i])
$(".years").click(function(){"#morphs").transition().ease("sin").duration(2000).tween("withchange", function() {
return function(t) {
}).attr("value", $(this).text() == "2005-2014" ? 0 : 1)
<div style="text-align:center;font-size: 19px;">
<span class="years">2005-2014</span>
<input id="morphs" type="range" min="0" max="1" step=".01" value="0" style="vertical-align: bottom"/>
<span class="years">2015-2025</span>
<br><span id="star" style="color:#C00000;font-size:32px;">&#x2736;</span>
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.