|
<!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 = 100; |
|
|
|
var points = [ |
|
{ name: "Ft. Funston", coordinates: [-122.501464,37.709899] }, |
|
{ name: "SF Zoo", coordinates: [-122.506528,37.732643] }, |
|
{ name: "The Sunset", coordinates: [-122.509789,37.753140] }, |
|
{ name: "Golden Gate Park", coordinates: [-122.510648,37.765897] }, |
|
{ name: "The Richmond", coordinates: [-122.511506,37.774989] }, |
|
{ name: "Lands End", coordinates: [-122.506356,37.787471] }, |
|
{ name: "Sea Cliff", coordinates: [-122.487216,37.789574] }, |
|
{ name: "GGB", coordinates: [-122.477174,37.810733] }, |
|
{ name: "Crissy Field", coordinates: [-122.453570,37.806258] }, |
|
{ name: "Fort Mason", coordinates: [-122.430997,37.807207] }, |
|
{ name: "Fisherman's Wharf", coordinates: [-122.416406,37.809445] }, |
|
{ name: "Exploratorium", coordinates: [-122.398859,37.801036] }, |
|
{ name: "Ferry Building", coordinates: [-122.393135,37.795508] }, |
|
{ name: "Bay Bridge", coordinates: [-122.387545,37.789302] }, |
|
{ name: "AT&T Park", coordinates: [-122.387846,37.778449] }, |
|
{ name: "The Ramp", coordinates: [-122.386783,37.764557] }, |
|
{ name: "Pier 70", coordinates: [-122.381607,37.756457] }, |
|
{ name: "Islais Creek", coordinates: [-122.394562,37.748254] }, |
|
{ name: "Heron's Head Park", coordinates: [-122.374885,37.738515] }, |
|
{ name: "Hunters Point", coordinates: [-122.365497,37.727467] }, |
|
{ name: "Burrito Railgun", coordinates: [-122.361013,37.719982] }, |
|
{ name: "Candlestick Point", coordinates: [-122.380357,37.709356] } |
|
|
|
]; |
|
|
|
|
|
var projection = d3.geo.conicConformal() |
|
.parallels([37 + 4 / 60, 38 + 26 / 60]) |
|
.rotate([120 + 30 / 60, -36 - 30 / 60]) |
|
.scale(150000) |
|
.translate([5300, 4400]); |
|
|
|
var line = d3.svg.line(); |
|
|
|
// Top point |
|
var origin = [50, 100]; |
|
|
|
d3.json("ca.geojson",function(err,ca){ |
|
|
|
// Preproject to screen coords |
|
ca.coordinates[0] = ca.coordinates[0].map(projection); |
|
points.forEach(function(point){ |
|
point.coordinates = projection(point.coordinates); |
|
}); |
|
|
|
// Move the starting point 54 points earlier (Ft. Funston-ish) |
|
windBackwards(ca.coordinates[0], 54); |
|
|
|
// Get coastline (54 points longer than before) |
|
var ls = ca.coordinates[0].slice(0, 1034); |
|
|
|
// Get simplified vertices |
|
var simplified = simplify(ls, 1500); |
|
|
|
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") |
|
.attr("transform", function(d) { |
|
return "rotate(-23)" |
|
}); |
|
zones.call(update); |
|
|
|
// Step-by-step for demo purposes |
|
d3.select("body") |
|
.transition() |
|
.duration(2000) |
|
.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 |
|
|
|
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); |
|
|
|
d3.selectAll("text").transition() |
|
.duration(1000) |
|
.each("end",function(){ |
|
d3.select(this).transition().duration(500).style("text-anchor", "left") |
|
.attr("transform","rotate(-90)") |
|
.attr("dx", "-1.0em") |
|
.attr("dy", "0.28em"); |
|
}); |
|
|
|
|
|
|
|
} |
|
|
|
// 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(ca); |
|
}); |
|
|
|
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; |
|
} |
|
|
|
function windBackwards(arr, num) { |
|
|
|
arr.pop(); |
|
|
|
for (var i = 0; i < num; i++) { |
|
arr.unshift(arr.pop()); |
|
} |
|
|
|
arr.push(arr[0]); |
|
|
|
} |
|
|
|
d3.select(self.frameElement).style("height", "720px"); |
|
|
|
</script> |
|
</body> |
|
</html> |