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
xxxxxxxxxx
<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>
https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.10/d3.min.js
https://cdnjs.cloudflare.com/ajax/libs/queue-async/1.0.7/queue.min.js