Skip to content

Instantly share code, notes, and snippets.

@christophermanning
Last active May 3, 2018 16:04
Show Gist options
  • Save christophermanning/2271944 to your computer and use it in GitHub Desktop.
Save christophermanning/2271944 to your computer and use it in GitHub Desktop.
Chicago Ward Remap Outlines

Created by Christopher Manning

Summary

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.

Controls

  • 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.

Notes

  • 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

References

View this gist at bl.ocks.org

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html lang="en">
<head>
<title>Chicago Ward Remap Outlines</title>
<script src="//d3js.org/d3.v2.min.js"></script>
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.8/jquery.min.js"></script>
<script type="text/javascript" src="https://raw.github.com/fryn/html5slider/master/html5slider.js"></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;
}
</style>
</head>
<body>
<div id="vis"></div>
<script type="text/javascript">
$(function() {
//http://stackoverflow.com/a/901144/678708
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 "";
else
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 = d3.select("#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(d.properties.WARD)
}).sort(function(a, b) {
return a.properties.WARD - b.properties.WARD
})])
d3.json("wards_2015.json", function(json) {
var wardIndex = 0
wards_json.push([json.features.map(function(d) {
//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': d.properties}
}).sort(function(a, b) {
return a.properties.WARD - b.properties.WARD
})])
dataWards = wards.selectAll("path").data(wards_json[0][0], function(d) {
return parseInt(d.properties.WARD)
})
drawWards()
//demo
$(".years:last").trigger("click")
})
})
function transformWard(d, i) {
if (!alignToGrid) return
//there isn't an easy way to absolutely position a path in a SVG
x = (d.properties.WARD - 1) % 10
y = Math.floor((d.properties.WARD - 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) {
return d.properties.WARD
})
}
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
drawWards()
}, function() {
alignToGrid = true
drawWards()
})
$("#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][d.properties.WARD - 1].geometry.coordinates[0], wards_json[1][0][d.properties.WARD - 1].geometry.coordinates[0])(val)]}
return path(geometryCache[i])
})
})
$(".years").click(function(){
d3.select("#morphs").transition().ease("sin").duration(2000).tween("withchange", function() {
return function(t) {
$(this).trigger("change")
};
}).attr("value", $(this).text() == "2005-2014" ? 0 : 1)
})
})
</script>
<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>
</div>
</body>
</html>
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Loading
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