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.
xxxxxxxxxx
<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://cdn.jsdelivr.net/gh/fryn/html5slider/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() {
//https://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;">✶</span>
</div>
</body>
</html>
Updated missing url https://raw.github.com/fryn/html5slider/master/html5slider.js to https://cdn.jsdelivr.net/gh/fryn/html5slider/html5slider.js
https://d3js.org/d3.v2.min.js
https://ajax.googleapis.com/ajax/libs/jquery/1.8/jquery.min.js
https://raw.github.com/fryn/html5slider/master/html5slider.js