|
<!DOCTYPE html> |
|
<meta charset="utf-8"> |
|
<body> |
|
<script src="http://d3js.org/d3.v3.min.js"></script> |
|
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/dat-gui/0.5/dat.gui.min.js"></script> |
|
<script src="http://d3js.org/topojson.v1.min.js"></script> |
|
<script> |
|
width = Math.max(window.innerWidth, 960) |
|
height = Math.max(window.innerHeight, 500) |
|
config = {"radius": 3}; |
|
dotColor = 'RED' |
|
waterColor = '#5ad4e1' |
|
landColor = '#ccc' |
|
borderColor = '#fff' |
|
|
|
gui = new dat.GUI({width: 130}); |
|
radius = gui.add(config, "radius", 2, 20).listen() |
|
radius.onChange(function(value) { |
|
draw() |
|
}); |
|
|
|
var projection = d3.geo.albersUsa() |
|
.scale(1000) |
|
.translate([(width-100) / 2, (height-25) / 2]); |
|
|
|
var zoom = d3.behavior.zoom() |
|
.scale(Math.ceil(config["radius"])) |
|
.scaleExtent([2, 20]) |
|
.on("zoom", function(d,i) { |
|
config["radius"] = Math.floor(d3.event.scale) |
|
draw() |
|
}); |
|
|
|
canvas = d3.select("body").append("canvas") |
|
.attr("width", width) |
|
.attr("height", height) |
|
.call(zoom) |
|
|
|
context = canvas.node().getContext("2d") |
|
|
|
path = d3.geo.path() |
|
.projection(projection) |
|
.context(context); |
|
|
|
d3.json('/christophermanning/raw/8203750/us.json', function(error, us) { |
|
lands = topojson.feature(us, us.objects.land) |
|
states = topojson.feature(us, us.objects.states) |
|
draw() |
|
}) |
|
|
|
function draw() { |
|
// style map |
|
context.fillStyle = waterColor; |
|
context.fillRect(0, 0, width, height) |
|
|
|
// draw state boundaries |
|
context.beginPath(); |
|
path.context(context)(states); |
|
context.lineWidth = 2; |
|
context.strokeStyle = borderColor; |
|
context.fillStyle = landColor |
|
context.fill(); |
|
context.stroke(); |
|
|
|
// draw landmass boundaries |
|
context.beginPath(); |
|
path.context(context)(lands); |
|
context.strokeStyle = waterColor |
|
context.stroke(); |
|
|
|
// draw dots |
|
dots = 0 |
|
context.fillStyle = dotColor; |
|
|
|
for (var i0 = 0; i0 < lands.geometry.coordinates.length; i0++) { |
|
// significant landmasses |
|
if([0, 79, 80, 89, 85, 86, 81, 123, 104, 105, 106, 107, 108, 109, 101, 108, 116].indexOf(i0) == -1) continue; |
|
|
|
// convert us landmass into single geometry |
|
if(i0 == 0) { |
|
p = [lands.geometry.coordinates[i0][1]] |
|
} else { |
|
p = lands.geometry.coordinates[i0] |
|
} |
|
|
|
feature = {type: "Feature", geometry: {type: "Polygon", coordinates: p}} |
|
|
|
// possion-disc sampling |
|
bounds = path.bounds(feature) |
|
sample = poissonDiscSampler(bounds[1][0] - bounds[0][0], bounds[1][1] - bounds[0][1], config['radius']*2, p); |
|
s = true |
|
while(s) { |
|
var s = sample() |
|
if (s) { |
|
x = bounds[0][0]+s[0] |
|
y = bounds[0][1]+s[1] |
|
|
|
if(pointInPolygon(projection.invert([x,y]), p[0])) { |
|
context.beginPath(); |
|
context.arc(x, y, config['radius'], 0, 2 * Math.PI, false); |
|
context.fill() |
|
dots++ |
|
} |
|
} |
|
} |
|
} |
|
|
|
context.fillStyle = borderColor |
|
context.font = 'bold 1.5em Verdana'; |
|
context.fillText(dots.toLocaleString() + ' Red Dots', width - 215, 200) |
|
} |
|
|
|
// PNPOLY |
|
function pointInPolygon(point, polygon) { |
|
for (var n = polygon.length, i = 0, j = n - 1, x = point[0], y = point[1], inside = false; i < n; j = i++) { |
|
var xi = polygon[i][0], yi = polygon[i][1], |
|
xj = polygon[j][0], yj = polygon[j][1]; |
|
if ((yi > y ^ yj > y) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside; |
|
} |
|
return inside; |
|
} |
|
|
|
// Based on https://www.jasondavies.com/poisson-disc/ |
|
function poissonDiscSampler(width, height, radius) { |
|
var k = 30, // maximum number of samples before rejection |
|
radius2 = radius * radius, |
|
R = 3 * radius2, |
|
cellSize = radius * Math.SQRT1_2, |
|
gridWidth = Math.ceil(width / cellSize), |
|
gridHeight = Math.ceil(height / cellSize), |
|
grid = new Array(gridWidth * gridHeight), |
|
queue = [], |
|
queueSize = 0, |
|
sampleSize = 0; |
|
|
|
return function() { |
|
if (!sampleSize) return sample(Math.random() * width, Math.random() * height); |
|
|
|
// Pick a random existing sample and remove it from the queue. |
|
while (queueSize) { |
|
var i = Math.random() * queueSize | 0, |
|
s = queue[i]; |
|
|
|
// Make a new candidate between [radius, 2 * radius] from the existing sample. |
|
for (var j = 0; j < k; ++j) { |
|
var a = 2 * Math.PI * Math.random(), |
|
r = Math.sqrt(Math.random() * R + radius2), |
|
x = s[0] + r * Math.cos(a), |
|
y = s[1] + r * Math.sin(a); |
|
|
|
// Reject candidates that are outside the allowed extent, |
|
// or closer than 2 * radius to any existing sample. |
|
if (0 <= x && x < width && 0 <= y && y < height && far(x, y)) return sample(x, y); |
|
} |
|
|
|
queue[i] = queue[--queueSize]; |
|
queue.length = queueSize; |
|
} |
|
}; |
|
|
|
function far(x, y) { |
|
var i = x / cellSize | 0, |
|
j = y / cellSize | 0, |
|
i0 = Math.max(i - 2, 0), |
|
j0 = Math.max(j - 2, 0), |
|
i1 = Math.min(i + 3, gridWidth), |
|
j1 = Math.min(j + 3, gridHeight); |
|
|
|
for (j = j0; j < j1; ++j) { |
|
var o = j * gridWidth; |
|
for (i = i0; i < i1; ++i) { |
|
if (s = grid[o + i]) { |
|
var s, |
|
dx = s[0] - x, |
|
dy = s[1] - y; |
|
if (dx * dx + dy * dy < radius2) return false; |
|
} |
|
} |
|
} |
|
|
|
return true; |
|
} |
|
|
|
function sample(x, y) { |
|
var s = [x, y]; |
|
queue.push(s); |
|
grid[gridWidth * (y / cellSize | 0) + (x / cellSize | 0)] = s; |
|
++sampleSize; |
|
++queueSize; |
|
return s; |
|
} |
|
} |
|
</script> |