|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="utf-8" /> |
|
<style> |
|
|
|
path { |
|
fill: none; |
|
stroke-width: 2px; |
|
stroke-linejoin: round; |
|
} |
|
|
|
text { |
|
font: 14px Helvetica, Arial, sans-serif; |
|
text-anchor: end; |
|
} |
|
|
|
.state { |
|
stroke: #999; |
|
stroke-width: 1px; |
|
fill: papayawhip; |
|
} |
|
|
|
.simplified { |
|
stroke: #de1e3d; |
|
stroke-width: 2px; |
|
stroke-dasharray: 8,8; |
|
} |
|
|
|
.zone { |
|
stroke: #0eb8ba; |
|
} |
|
|
|
.hidden { |
|
display: none; |
|
} |
|
|
|
</style> |
|
</head> |
|
<body> |
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.17/d3.min.js"></script> |
|
<script src="warper.js"></script> |
|
<script src="simplify.js"></script> |
|
<script> |
|
|
|
var stripWidth = 80; |
|
|
|
var points = [ |
|
{ name: "New Buffalo", coordinates: [ -86.752233, 41.798442] }, |
|
{ name: "Muskegon", coordinates: [-86.259203,43.236337] }, |
|
{ name: "Traverse City", coordinates: [-85.620889,44.765192] }, |
|
{ name: "Mackinaw City", coordinates: [-84.730492, 45.788204] }, |
|
{ name: "Alpena", coordinates: [-83.428751,45.059565] }, |
|
{ name: "Bay City", coordinates: [-83.859883,43.646888] }, |
|
{ name: "Detroit", coordinates: [-83.041049,42.327537] } |
|
]; |
|
|
|
var projection = d3.geo.conicConformal() |
|
.parallels([42, 45]) |
|
.rotate([84, -35 - 20 / 60]) |
|
.scale(3433) |
|
.translate([355, 798]); |
|
|
|
var line = d3.svg.line(); |
|
|
|
// Top point |
|
var origin = [50, 100]; |
|
|
|
d3.json("mi.geojson",function(err,mi){ |
|
|
|
// Preproject to screen coords |
|
mi.coordinates[0] = mi.coordinates[0].map(projection); |
|
points.forEach(function(point){ |
|
point.coordinates = projection(point.coordinates); |
|
}); |
|
|
|
// Get coastline |
|
var ls = mi.coordinates[0].slice(102, 1700); |
|
|
|
// Get simplified vertices |
|
var simplified = simplify(ls, 675); |
|
|
|
var zones = d3.select("body").append("svg") |
|
.attr("width", 960) |
|
.attr("height", 720) |
|
.selectAll("g") |
|
.data(getZones(simplified)) |
|
.enter() |
|
.append("g"); |
|
|
|
zones.append("defs") |
|
.append("clipPath") |
|
.attr("id",function(d, i){ |
|
return "clip" + i; |
|
}) |
|
.append("path"); |
|
|
|
var inner = zones.append("g") |
|
.attr("class",function(d, i) { |
|
return i ? "hidden" : null; |
|
}); |
|
|
|
inner.append("path") |
|
.attr("class", "state"); |
|
|
|
inner.append("line") |
|
.attr("class", "simplified fade hidden"); |
|
|
|
// Put boundary outside so it isn't clipped |
|
zones.append("path") |
|
.attr("class", "zone fade hidden"); |
|
|
|
// Only put cities in zones they actually fall in |
|
var cities = zones.selectAll(".city") |
|
.data(function(d, i){ |
|
return points.filter(function(point){ |
|
if (pip(point.coordinates, d.boundary)) { |
|
return point.zone = d; |
|
} |
|
}); |
|
}) |
|
.enter() |
|
.append("g") |
|
.attr("class", "city"); |
|
|
|
cities.append("circle") |
|
.attr("r", 3); |
|
|
|
cities.append("text") |
|
.text(function(d){ |
|
return d.name; |
|
}) |
|
.attr("dx", "-0.5em") |
|
.attr("dy", "0.35em"); |
|
|
|
zones.call(update); |
|
|
|
// // Step-by-step for demo purposes |
|
d3.select("body") |
|
.transition() |
|
.duration(1000) |
|
.each("end", clipState) |
|
.transition() |
|
.each("end", showLine) |
|
.transition() |
|
.each("end", showZones) |
|
.transition() |
|
.each("end", move); |
|
|
|
// 1. Clip out the rest of CA |
|
function clipState() { |
|
inner.classed("hidden", false) |
|
.attr("clip-path",function(d, i){ |
|
return "url(#clip" + i + ")"; |
|
}); |
|
} |
|
|
|
// 2. Show the simplified line |
|
function showLine() { |
|
inner.select(".simplified") |
|
.classed("hidden", false); |
|
} |
|
|
|
// 3. Show the zone boundaries |
|
function showZones() { |
|
zones.select(".zone") |
|
.classed("hidden", false); |
|
} |
|
|
|
// 4. Rotate/translate all the zones |
|
function move() { |
|
|
|
warpZones(zones.data()); |
|
|
|
// Flip text orientation |
|
d3.selectAll("text").transition() |
|
.duration(1000) |
|
.each("end",function(){ |
|
d3.select(this).style("text-anchor", "middle") |
|
.attr("dx", 0) |
|
.attr("dy", "1.5em"); |
|
}); |
|
|
|
zones.transition() |
|
.duration(2000) |
|
.each("end",align) |
|
.call(update); |
|
|
|
} |
|
|
|
// 5. Warp the zones to rectangles |
|
function align(z) { |
|
|
|
z.project = function(d){ |
|
return z.warp(z.translate(d)); |
|
}; |
|
|
|
z.boundary = z.corners; |
|
|
|
d3.select(this) |
|
.transition() |
|
.duration(750) |
|
.call(update) |
|
.each("end",fade); |
|
|
|
} |
|
|
|
// 6. Fade out |
|
function fade() { |
|
|
|
d3.select(this).selectAll(".fade") |
|
.transition() |
|
.duration(500) |
|
.style("opacity", 0); |
|
|
|
} |
|
|
|
// Redraw |
|
function update(sel) { |
|
|
|
sel.select(".zone") |
|
.attr("d",function(d){ |
|
return line(d.boundary.slice(0,4)) + "Z"; |
|
}); |
|
|
|
sel.select(".state") |
|
.attr("d",function(d){ |
|
return d.path(mi); |
|
}); |
|
|
|
sel.select(".simplified") |
|
.attr("x1",function(d){ |
|
return d.ends[0][0]; |
|
}) |
|
.attr("x2",function(d){ |
|
return d.ends[1][0]; |
|
}) |
|
.attr("y1",function(d){ |
|
return d.ends[0][1]; |
|
}) |
|
.attr("y2",function(d){ |
|
return d.ends[1][1]; |
|
}); |
|
|
|
sel.select("clipPath path") |
|
.attr("d",function(d){ |
|
return line(d.boundary.slice(0,4)) + "Z"; |
|
}); |
|
|
|
sel.selectAll(".city") |
|
.attr("transform",function(d){ |
|
return "translate(" + d.zone.project(d.coordinates) + ")"; |
|
}); |
|
|
|
} |
|
|
|
}); |
|
|
|
// Turn a simplified LineString into one group per segment |
|
function getZones(simp) { |
|
|
|
return simp.slice(1).map(function(p, i){ |
|
|
|
return { |
|
boundary: getBoundary(simp[i - 1], simp[i], p, simp[i + 2]), |
|
ends: [simp[i], p], |
|
project: id, |
|
path: d3.geo.path().projection(null) |
|
}; |
|
|
|
}); |
|
|
|
} |
|
|
|
function warpZones(zones) { |
|
|
|
zones.forEach(function(z,i){ |
|
|
|
var angle = getAngle(z.ends[0], z.ends[1]), |
|
anchor = i ? zones[i - 1].ends[1] : origin; |
|
|
|
// Anchor points to end of prev segment |
|
var translate = [ |
|
anchor[0] - z.ends[0][0], |
|
anchor[1] - z.ends[0][1] |
|
]; |
|
|
|
// Get translation/rotation function |
|
z.translate = translateAndRotate(translate, z.ends[0], angle); |
|
|
|
// Warp the boundary line and the simplified segment |
|
z.ends = z.ends.map(z.translate); |
|
z.boundary = z.boundary.map(z.translate); |
|
|
|
var top = bisect(null, z.ends[0], z.ends[1]), |
|
bottom = bisect(z.ends[0], z.ends[1], null); |
|
|
|
z.corners = [top[0], top[1], bottom[1], bottom[0], top[0]]; |
|
|
|
z.corners.push(z.corners[0]); |
|
|
|
// See: http://bl.ocks.org/veltman/8f5a157276b1dc18ce2fba1bc06dfb48 |
|
z.warp = warper(z.boundary, z.corners); |
|
|
|
z.project = function(d){ |
|
return z.translate(d); |
|
}; |
|
|
|
z.path.projection(d3.geo.transform({ |
|
point: function(x, y) { |
|
var p = z.project([x, y]); |
|
this.stream.point(p[0], p[1]); |
|
} |
|
})); |
|
|
|
}); |
|
|
|
} |
|
|
|
function getBoundary(prev, first, second, next) { |
|
|
|
// if prev is undefined, top is perpendicular through first |
|
// otherwise top bisects the prev-first-second angle |
|
// if next is undefined, bottom is perpendicular through second |
|
// otherwise bottom bisects the first-second-next angle |
|
var top = bisect(prev, first, second), |
|
bottom = bisect(first, second, next); |
|
|
|
return [top[0], top[1], bottom[1], bottom[0], top[0]]; |
|
} |
|
|
|
function getAngle(a, b) { |
|
|
|
return Math.atan2(b[1] - a[1], b[0] - a[0]); |
|
|
|
} |
|
|
|
// Given an anchor point, initial translate, and angle rotation |
|
// Return a function to translate+rotate a point |
|
function translateAndRotate(translate, anchor, angle) { |
|
|
|
var cos = Math.cos(angle), |
|
sin = Math.sin(angle); |
|
|
|
return function(point) { |
|
|
|
return [ |
|
translate[0] + anchor[0] + ( cos * (point[0] - anchor[0]) + sin * (point[1] - anchor[1])), |
|
translate[1] + anchor[1] + ( -sin * (point[0] - anchor[0]) + cos * (point[1] - anchor[1])) |
|
]; |
|
|
|
}; |
|
|
|
} |
|
|
|
// Hacky angle bisector |
|
function bisect(start, vertex, end) { |
|
|
|
var at, |
|
bt, |
|
adjusted, |
|
right, |
|
left; |
|
|
|
if (start) { |
|
at = getAngle(start, vertex); |
|
} |
|
|
|
if (end) { |
|
bt = getAngle(vertex, end); |
|
} |
|
|
|
if (!start) { |
|
at = bt; |
|
} |
|
|
|
if (!end) { |
|
bt = at; |
|
} |
|
|
|
adjusted = bt - at; |
|
|
|
if (adjusted <= -Math.PI) { |
|
adjusted = 2 * Math.PI + adjusted; |
|
} else if (adjusted > Math.PI) { |
|
adjusted = adjusted - 2 * Math.PI; |
|
} |
|
|
|
right = (adjusted - Math.PI) / 2; |
|
left = Math.PI + right; |
|
|
|
left += at; |
|
right += at; |
|
|
|
return [ |
|
[vertex[0] + stripWidth * Math.cos(left) / 2, vertex[1] + stripWidth * Math.sin(left) / 2], |
|
[vertex[0] + stripWidth * Math.cos(right) / 2, vertex[1] + stripWidth * Math.sin(right) / 2] |
|
]; |
|
} |
|
|
|
// https://github.com/substack/point-in-polygon |
|
// based on http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html |
|
function pip(point, vs) { |
|
|
|
var x = point[0], |
|
y = point[1], |
|
inside = false; |
|
|
|
for (var i = 0, j = vs.length - 1; i < vs.length; j = i++) { |
|
|
|
var xi = vs[i][0], yi = vs[i][1]; |
|
var xj = vs[j][0], yj = vs[j][1]; |
|
|
|
var intersect = ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi); |
|
if (intersect) { |
|
inside = !inside; |
|
} |
|
|
|
} |
|
|
|
return inside; |
|
|
|
} |
|
|
|
function id(d) { |
|
return d; |
|
} |
|
|
|
d3.select(self.frameElement).style("height", "720px"); |
|
|
|
</script> |
|
</body> |
|
</html> |