Experimenting with an in-browser hexbinner.
To dos: add k-means classification option, allow drag-and-drop data uploads, projection selection, more color scales
See also: Resizing hexbin test
Experimenting with an in-browser hexbinner.
To dos: add k-means classification option, allow drag-and-drop data uploads, projection selection, more color scales
See also: Resizing hexbin test
<!DOCTYPE html> | |
<meta charset="utf-8"> | |
<link href="https://fonts.googleapis.com/css?family=Open+Sans" rel="stylesheet" type="text/css"> | |
<style> | |
body,button { | |
font: 12px "Open Sans", sans-serif; | |
} | |
* { | |
box-sizing: border-box; | |
-moz-box-sizing: border-box; | |
-webkit-box-sizing: border-box; | |
} | |
a { | |
text-decoration: none; | |
} | |
h2 { | |
margin: 0 0 0.25em 0; | |
font-size: 1.5em; | |
} | |
h2:first-of-type { | |
margin-top: 0; | |
} | |
.controls { | |
position: absolute; | |
top: 1em; | |
left: 1em; | |
padding: 1em; | |
border: 1px solid #444; | |
} | |
.controls > div { | |
margin-bottom: 1em; | |
} | |
.controls > div:last-of-type { | |
margin-bottom: 0; | |
} | |
canvas { | |
position: absolute; | |
left: 0; | |
top: 0; | |
} | |
.handle { | |
fill: #fff; | |
stroke: #000; | |
stroke-opacity: .5; | |
stroke-width: 1.25px; | |
cursor: crosshair; | |
} | |
.axis { | |
pointer-events: none; | |
} | |
.axis .domain { | |
fill: none; | |
stroke: #000; | |
stroke-opacity: .3; | |
stroke-width: 10px; | |
stroke-linecap: round; | |
} | |
.axis .halo { | |
fill: none; | |
stroke: #ddd; | |
stroke-width: 8px; | |
stroke-linecap: round; | |
} | |
.button { | |
background-color: #fff; | |
border: 1px solid #ccc; | |
color: #333; | |
padding: 0.5em 1em; | |
line-height: 140%; | |
vertical-align: middle; | |
cursor: pointer; | |
text-align: center; | |
display: inline-block; | |
} | |
.button:hover, | |
.button:focus, | |
.button:active, | |
.button.active { | |
background-color: #ebebeb; | |
border-color: #adadad; | |
} | |
.button:active, | |
.button.active { | |
color: #000; | |
outline: 0; | |
-webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); | |
box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); | |
} | |
.scale .button { | |
width: 50%; | |
} | |
.num-classes .button { | |
width: 20%; | |
} | |
.colors .button { | |
width: 25%; | |
} | |
.colors .button > div { | |
height: 1em; | |
width: 100%; | |
} | |
.colors .button > div div { | |
width: 20%; | |
display: inline-block; | |
height: 100%; | |
} | |
</style> | |
<body> | |
<canvas></canvas> | |
<div class="controls"> | |
<div class="size"> | |
<h2>Hexagon Size</h2> | |
</div> | |
<div class="scale"> | |
<h2>Classification</h2> | |
<div> | |
<button class="button active">Quantiles</button><button class="button">Equal Intervals</button> | |
</div> | |
</div> | |
<div class="num-classes"> | |
<h2>Number of Classes</h2> | |
<div> | |
<button class="button">3</button><button class="button">4</button><button class="button active">5</button><button class="button">6</button><button class="button">7</button> | |
</div> | |
</div> | |
<div class="colors"> | |
<h2>Colors</h2> | |
<div> | |
<div class="button active"></div><div class="button"></div><div class="button"></div><div class="button"></div> | |
</div> | |
</div> | |
<div class="save"> | |
<a href="#" class="button" download="hexmap.png">Save Image</a> | |
</div> | |
</div> | |
<script src="//cdnjs.cloudflare.com/ajax/libs/d3/3.5.10/d3.min.js"></script> | |
<script src="//cdnjs.cloudflare.com/ajax/libs/queue-async/1.0.7/queue.min.js"></script> | |
<script src="hexbin.js"></script> | |
<script src="colorbrewer.min.js"></script> | |
<script> | |
// More NYC-appropriate aspect ratio for bl.ocks.org | |
d3.select(self.frameElement).style("height", "960px"); | |
var canvas = d3.select("canvas"), | |
context = d3.select("canvas").node().getContext("2d"); | |
// NY state plane | |
var projection = d3.geo.conicConformal() | |
.parallels([40 + 2 / 3, 41 + 1 / 30]) | |
.rotate([74, 40 + 1 / 6]); | |
var path = d3.geo.path() | |
.projection(projection); | |
var color = d3.scale.quantile() | |
.range(colorbrewer.PuRd[5]); | |
// Scale for hexagon radius | |
var radius = d3.scale.linear() | |
.range([2,30]); | |
var points, | |
bg, | |
clip; | |
var hexbinner = d3.hexbin().radius(5); | |
// Get data | |
queue() | |
.defer(d3.json,"nyc.geojson") | |
.defer(d3.csv,"service-requests-311.csv") // 41k random 311 service request locations | |
.await(ready); | |
function ready(err,nyc,data) { | |
// Convert to lng/lat pairs | |
points = data.map(function(p){ | |
return [+p.lng,+p.lat]; | |
}); | |
// Save bg geography lazily | |
bg = nyc; | |
// Draw/add listeners for menus | |
addHexSize(); | |
addScales(); | |
addNumClasses(); | |
addColors(); | |
d3.select(".save .button").on("click",saveCanvas); | |
// Keep canvas responsive | |
window.onresize = resize; | |
// Set size once | |
resize(); | |
} | |
// Redraw everything | |
function rehex() { | |
// Wipe map | |
context.clearRect(0,0,window.innerWidth,window.innerHeight); | |
// Rebin | |
var bins = hexbinner(points.map(projection)); | |
// Sorted list of bin counts | |
var domain = bins.map(function(b){ | |
return b.length; | |
}).sort(function(a,b){ | |
return a-b; | |
}); | |
// Update scale domain | |
color.domain(domain); | |
context.fillStyle = "#fff"; | |
context.strokeStyle = "#999"; | |
context.fill(clip); | |
// Clip to background layer | |
context.globalCompositeOperation = "source-atop"; | |
var hex = new Path2D(hexbinner.hexagon()); | |
bins.forEach(function(bin){ | |
// Draw hexagon | |
context.translate(bin.x,bin.y); | |
context.fillStyle = color(bin.length); | |
context.fill(hex); | |
// Reset transform | |
context.setTransform(1, 0, 0, 1, 0, 0); | |
}); | |
// Doing this on every redraw to avoid weird edges | |
context.stroke(clip); | |
updateLegend(); | |
} | |
function updateLegend() { | |
var domain = color.domain(), | |
range = color.range(), | |
min = domain[0], | |
max = domain[domain.length - 1], | |
breaks; | |
if (color.quantiles) { | |
breaks = color.quantiles(); | |
breaks.unshift(min); | |
} else { | |
breaks = d3.range(range.length).map(function(i) { | |
return min + (i * (max - min)/range.length); | |
}); | |
} | |
// Start at next int for counts | |
breaks = breaks.map(Math.ceil); | |
context.globalCompositeOperation = "source-over"; | |
context.textAlign = "left"; | |
context.textBaseline = "top"; | |
context.strokeStyle = "#444"; | |
// Put legend below the Rockaways | |
var translate = projection([-73.95206451416014,40.53298071625918]); | |
// Don't stretch legend too far | |
var width = projection([-73.73542785644531,40.594663726004995])[0] - translate[0]; | |
context.translate(translate[0],translate[1]); | |
// Clear legend labels lazily | |
context.clearRect(0, 20, 1000, 1000); | |
breaks.forEach(function(b,i){ | |
var text = b; | |
context.fillStyle = range[i]; | |
context.fillRect(0, 0, width / range.length, 20); | |
if (i === breaks.length - 1) { | |
text += "+"; | |
} | |
context.strokeText(text, 0, 20); | |
context.translate(width / range.length,0); | |
}); | |
// Leave it at the identity transform | |
context.setTransform(1, 0, 0, 1, 0, 0); | |
} | |
// Get new window size, update all dimensions + projection | |
function resize() { | |
var width = window.innerWidth, | |
height = window.innerHeight; | |
hexbinner.size([width, height]); | |
canvas.attr("width",width) | |
.attr("height",height); | |
projection.scale(1) | |
.translate([0,0]); | |
var b = path.bounds(bg), | |
s = .95 / Math.max((b[1][0] - b[0][0]) / width, (b[1][1] - b[0][1]) / height), | |
t = [(width - s * (b[1][0] + b[0][0])) / 2, (height - s * (b[1][1] + b[0][1])) / 2]; | |
projection | |
.scale(s) | |
.translate(t); | |
// Only need to update this onresize | |
clip = new Path2D(path(bg)); | |
rehex(); | |
} | |
// Draw slider for hexagon size | |
function addHexSize(){ | |
var margin = { left: 10, right: 10, top: 8, bottom: 22}, | |
width = 250 - margin.left - margin.right, | |
height = 36 - margin.top - margin.bottom, | |
radius = hexbinner.radius(); | |
var x = d3.scale.linear() | |
.domain([2, 50]) | |
.range([0, width]) | |
.clamp(true); | |
var brush = d3.svg.brush() | |
.x(x) | |
.extent([radius,radius]) | |
.on("brush", brushed); | |
var axis = d3.svg.axis() | |
.scale(x) | |
.orient("bottom") | |
.tickValues(x.domain()) | |
.tickFormat(function(d,i){ | |
return i ? "Larger" : "Smaller"; | |
}) | |
.tickSize(0) | |
.tickPadding(12); | |
var svg = d3.select(".size").append("svg") | |
.attr("width",width + margin.left + margin.right) | |
.attr("height",height + margin.top + margin.bottom) | |
.append("g") | |
.attr("transform", "translate(" + margin.left + "," + margin.top + ")"); | |
svg.append("g") | |
.attr("class", "x axis") | |
.attr("transform", "translate(0," + height / 2 + ")") | |
.call(axis) | |
.select(".domain") | |
.select(function() { return this.parentNode.appendChild(this.cloneNode(true)); }) | |
.attr("class", "halo"); | |
svg.selectAll("text") | |
.attr("dx",function(d,i){ | |
return i ? "4px" : "-4px"; | |
}) | |
.style("text-anchor",function(d,i){ | |
return i ? "end" : "start"; | |
}); | |
var slider = svg.append("g") | |
.attr("class", "slider") | |
.call(brush); | |
slider.selectAll(".extent,.resize") | |
.remove(); | |
slider.select(".background") | |
.attr("height", height); | |
var handle = slider.append("circle") | |
.attr("class","handle") | |
.attr("r",8) | |
.attr("cx",x(radius)) | |
.attr("cy",height/2); | |
function brushed(){ | |
value = x.invert(d3.mouse(this)[0]); | |
brush.extent([value, value]); | |
handle.attr("cx", x(value)); | |
hexbinner.radius(value); | |
rehex(); | |
} | |
} | |
// Menu for scale type | |
function addScales() { | |
var buttons = d3.selectAll(".scale button") | |
.data([d3.scale.quantile,d3.scale.quantize]); | |
buttons.on("click",function(scale){ | |
buttons.classed("active",function(f){ | |
return scale === f; | |
}); | |
var range = color.range(); | |
color = scale().range(range); | |
rehex(); | |
}); | |
} | |
// Menu for number of colors | |
function addNumClasses() { | |
var buttons = d3.selectAll(".num-classes button") | |
.data(d3.range(3,8)); | |
buttons.on("click",function(numClasses){ | |
buttons.classed("active",function(f){ | |
return numClasses === f; | |
}); | |
var colorScheme = d3.select(".colors .active").datum(); | |
color.range(colorbrewer[colorScheme][numClasses]); | |
rehex(); | |
}); | |
} | |
// Menu swatches for color schemes | |
function addColors() { | |
var buttons = d3.selectAll(".colors .button") | |
.data(["PuRd","Blues","OrRd","PiYG"]); | |
// Draw some swatches | |
buttons.append("div") | |
.attr("title",Object) | |
.selectAll("div") | |
.data(function(d){ | |
return colorbrewer[d][5]; | |
}) | |
.enter() | |
.append("div") | |
.style("background-color",Object); | |
buttons.on("click",function(colorScheme){ | |
buttons.classed("active",function(f){ | |
return colorScheme === f; | |
}); | |
var numClasses = color.range().length; | |
color.range(colorbrewer[colorScheme][numClasses]); | |
rehex(); | |
}); | |
} | |
// For downloadable image | |
function saveCanvas() { | |
var url = canvas.node().toDataURL(); | |
d3.select(this).attr("href",url); | |
} | |
</script> |
(Sorry about that, but we can’t show files that are this big right now.)