2

I have a map I've created with d3-tile. I've added code to show hexbins gathered from the dataset based on longitude and latitude. But the hexbins are not showing. I've only found examples that plot plain cartesian data in hexbins, not latitude longitudes with hexbins laid over a map made with d3-tile. Why are my hexbins not showing?

Here's how I define the projection, tiles and hexbins:

var projection = d3.geo.mercator()
   .scale((1 << 22) / 2 / Math.PI) 
   .translate([width / 2, height / 2]);

var tile = d3.geo.tile()
  .size([width, height]);

// define hexbins
var hexbin = d3.hexbin()
   .size([width, height])
   .radius(4);

And here's how I process my data and add hexbins to the map:

data.forEach(function (d) {
    d.lat = +d.lat;
    d.lon = +d.lon;
});

points = [];

// x,y maps to lng,lat - ref[2]
data.forEach(function (d) {
   d.lat = +d.lat;
   d.lon = +d.lon;
   var x = projection([d.lon, d.lat])[0];
   var y = projection([d.lon, d.lat])[1];
   points.push([x, y]);
});

// bin coords
var bins = hexbin(points);
var bins_n = []; // points per hexbin
bins.forEach(function (d) {
    bins_n.push(d.length);
});

// second of two scales for linear hexagon fill - ref[1]
var extent = d3.extent(bins_n);
var fill_scale2 = d3.scale.linear()
    .domain([extent[0], extent[1]])
    .range([0, 1]);

hex.selectAll(".hexagon")
    .data(hexbin(points))
    .enter()
    .append("path")
    .attr("class", function (d) { return "hexagon bin_" + d.length; })
    .attr("d", hexbin.hexagon())
    .attr("transform", function (d) {
         return "translate(" + d.x + "," + d.y + ")";
     })
     .style("fill", function (d) {
        return fill_scale1(fill_scale2(d.length));
     });
Andrew Reid
  • 37,021
  • 7
  • 64
  • 83
Tilou
  • 59
  • 6
  • How have you defined your projection? – Andrew Reid Feb 11 '19 at 16:47
  • var projection = d3.geo.mercator() .scale((1 << 22) / 2 / Math.PI) .translate([width / 2, height / 2]); – Tilou Feb 11 '19 at 18:07
  • That's a very large value for a scale - if you log the projected x,y coordinates (or `points` in your case), you'll probably see coordinates that aren't within the bounds of your svg. The scale for d3-tile is usually `1/Math.PI/2`: it projects the world to a one pixel square and then uses the zoom to translate and scale it across the screen. It is not the most intuitive method. The projection is causing your issues, but with the zoom, the hexagon is a little bit more complex than usual. I'm a bit busy at the moment, but will take a closer look in a bit. – Andrew Reid Feb 11 '19 at 20:24

1 Answers1

2

I'll use this example to build my answer. This zoomable example, as opposed to this example one, is zoomable.

Projection Scale

First, I'll just explain the scale using the above example. It uses a projection that has a starting scale of 1/tau: the 2 π radians of the world are stretched over one pixel. The translate is [0,0], so that 0°N, 0°E is at [0,0] of the SVG. The scale and translate of the map is managed by d3.zoom:

projection.scale(transform.k / Math.PI / 2)
  .translate([transform.x, transform.y]);

As k represents the zoom factor, and our starting map width was 1, k represents map width and height. Dividing by tau we get how many pixels of the map correspond to each radian of the earth. The translate centers the map.

The reason why you can't see any hexbins in your example is because you use a scale of that stretches the earth over and area 4194304 pixels wide (1<<22), but your hexbins only stretch over an area the size of your SVG. You see no hexagons because the extent of your SVG represents only a small geographic extent - some area of ocean north of the Bering Sea.

Also for reference: The relationship between map scale and map width is not consistent across all map projections

Adding hexbins (fixed)

If we want hexagonal binning with bins that remain the same geographical size regardless of zoom, we can set the radius and extent to reflect our initial projection (before applying the zoom):

var hexbin = d3.hexbin()
    .radius(0.01)
    .extent([[-0.5, -0.5], [0.5, 0.5]]); 

We can then pass it projected points, using the initial projection, and transform the hexagons based on the zoom while scaling the stroke width of the hexagons based on zoom scale:

var width = Math.max(960, window.innerWidth),
    height = Math.max(500, window.innerHeight);
 
var svg = d3.select("svg")
    .attr("width", width)
    .attr("height", height);
 
// Projection details:
var projection = d3.geoMercator()
    .scale(1 / Math.PI / 2)
    .translate([0, 0]);

var center = projection([0,0]); 

var tile = d3.tile()
    .size([width, height]);

// Zoom details:
var zoom = d3.zoom()
    .scaleExtent([1 << 11, 1 << 14])
    .on("zoom", zoomed);

// Layers for map
var raster = svg.append("g");  // holds tiles
var vector = svg.append("g");  // holds hexagons
var hexes;        // to hold hexagons

// Hexbin:
var hexbin = d3.hexbin()
    .radius(0.01)
    .extent([[-0.5, -0.5], [0.5, 0.5]]); // extent of the one pixel projection.

var color = d3.scaleLinear()
 .range(["rgba(255,255,255,0.1)","orange"])
    .domain([0, 5]);

d3.json("https://unpkg.com/world-atlas@1/world/110m.json", function(error, world) {
  
 // Create some hexbin data:
 var land = topojson.feature(world, world.objects.land);
 var data = d3.range(500).map(function(d) {
 while(true) {
  var lat = Math.random() * 170 - 70;
  var lon = Math.random() * 360 - 180;
  if(d3.geoContains(land,[lon,lat])) return projection([lon,lat]);
 }
 })
 
 // Create hex bins:
 hexes = vector.selectAll()
  .data(hexbin(data))
  .enter()
  .append("path")
 .attr("d", hexbin.hexagon(0.0085))
  .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; })
  .attr("fill", function(d) { return color(d.length); })
 .attr("stroke","black")

  svg
   .call(zoom)
   .call(zoom.transform, d3.zoomIdentity
   .translate(width / 2, height / 2)
   .scale(1 << 11)
   .translate(-center[0], -center[1]));
 
});    
    
function zoomed() {
  var transform = d3.event.transform;
  
  var tiles = tile
      .scale(transform.k)
      .translate([transform.x, transform.y])
      ();

  // Update projection
  projection
      .scale(transform.k / Math.PI / 2)
      .translate([transform.x, transform.y]);

  // Update vector holding hexes:
  vector.attr("transform","translate("+[transform.x,transform.y]+")scale("+transform.k+")" )
    .attr("stroke-width", 1/transform.k);

  // Update tiles:
  var image = raster
      .attr("transform", stringify(tiles.scale, tiles.translate))
    .selectAll("image")
    .data(tiles, function(d) { return d; });

  image.exit().remove();

  image.enter().append("image")
      .attr("xlink:href", function(d) { return "http://" + "abc"[d[1] % 3] + ".tile.openstreetmap.org/" + d[2] + "/" + d[0] + "/" + d[1] + ".png"; })
      .attr("x", function(d) { return d[0] * 256; })
      .attr("y", function(d) { return d[1] * 256; })
      .attr("width", 256)
      .attr("height", 256);
}

function stringify(scale, translate) {
  var k = scale / 256, r = scale % 1 ? Number : Math.round;
  return "translate(" + r(translate[0] * scale) + "," + r(translate[1] * scale) + ") scale(" + k + ")";
}
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/d3-tile.v0.0.min.js"></script>
<script src="https://d3js.org/d3-hexbin.v0.2.min.js"></script>
<script src="https://unpkg.com/topojson-client@3"></script>
<svg></svg>

Adding hexbins (updated with zoom)

However, if we want to change the bins scale as we zoom in the code is potentially a bit easier, but computationally more complex. To do so we recalculate the hexbins each zoom based on the projected coordinates of the points after applying the current projection:

var hexbin = d3.hexbin()
 .radius(30)
 .extent([[0,0], [width,height]]) // extent of projected data (displayed)
 .x(function(d) { return projection(d)[0]; })
 .y(function(d) { return projection(d)[1]; })

The extent and radius reflect the entire SVG extent - the visible extent to which we are projecting the data after the zoom is applied - the extent we want to add hexagons to. Below I recalculate the hexes each zoom/pan:

var width = Math.max(960, window.innerWidth),
    height = Math.max(500, window.innerHeight);
 
var svg = d3.select("svg")
    .attr("width", width)
    .attr("height", height);
 
// Projection details:
var projection = d3.geoMercator()
    .scale(1 / Math.PI / 2)
    .translate([0, 0]);

var center = projection([0,0]); 

var tile = d3.tile()
    .size([width, height]);

// Zoom details:
var zoom = d3.zoom()
    .scaleExtent([1 << 11, 1 << 14])
    .on("zoom", zoomed);

// Layers for map
var raster = svg.append("g");  // holds tiles
var vector = svg.append("g");  // holds hexagons
var hexes;        // to hold hexagons

// Hexbin:
var hexbin = d3.hexbin()
    .radius(30)
    .extent([[0,0], [width,height]]) // extent of projected data (displayed)
 .x(function(d) { return projection(d)[0]; })
 .y(function(d) { return projection(d)[1]; })

var color = d3.scaleLinear()
 .range(["rgba(255,255,255,0.1)","orange"])
    .domain([0, 5]);
 
var data;

d3.json("https://unpkg.com/world-atlas@1/world/110m.json", function(error, world) {
  
 // Create some hexbin data:
 var land = topojson.feature(world, world.objects.land);
 data = d3.range(500).map(function(d) {
 while(true) {
  var lat = Math.random() * 170 - 70;
  var lon = Math.random() * 360 - 180;
  if(d3.geoContains(land,[lon,lat])) return [lon,lat];
 }
 })
 
  svg
   .call(zoom)
   .call(zoom.transform, d3.zoomIdentity
   .translate(width / 2, height / 2)
   .scale(1 << 11)
   .translate(-center[0], -center[1]));
 
});    
    
function zoomed() {
  var transform = d3.event.transform;
  
  var tiles = tile
      .scale(transform.k)
      .translate([transform.x, transform.y])
      ();

  // Update projection
  projection
      .scale(transform.k / Math.PI / 2)
      .translate([transform.x, transform.y]);

  hexes = vector.selectAll("path")
   .data(hexbin(data)) ;
   
   hexes.exit().remove();
   
   hexes.enter()
   .append("path")
   .merge(hexes)
   .attr("d", hexbin.hexagon(29))
   .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; })
   .attr("fill", function(d) { return color(d.length); })
   .attr("stroke","black")

  // Update tiles:
  var image = raster
      .attr("transform", stringify(tiles.scale, tiles.translate))
    .selectAll("image")
    .data(tiles, function(d) { return d; });

  image.exit().remove();

  image.enter().append("image")
      .attr("xlink:href", function(d) { return "http://" + "abc"[d[1] % 3] + ".tile.openstreetmap.org/" + d[2] + "/" + d[0] + "/" + d[1] + ".png"; })
      .attr("x", function(d) { return d[0] * 256; })
      .attr("y", function(d) { return d[1] * 256; })
      .attr("width", 256)
      .attr("height", 256);
}

function stringify(scale, translate) {
  var k = scale / 256, r = scale % 1 ? Number : Math.round;
  return "translate(" + r(translate[0] * scale) + "," + r(translate[1] * scale) + ") scale(" + k + ")";
}
<svg></svg>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/d3-tile.v0.0.min.js"></script>
<script src="https://d3js.org/d3-hexbin.v0.2.min.js"></script>
<script src="https://unpkg.com/topojson-client@3"></script>

Both examples randomly create some data over land masses, this is the primary reason for the slow load

Last thoughts

Both examples leave a fair amount to be desired, the method of organizing coordinate spaces with d3-tile and the hexagons is a bit less intuitive than possible - and can take a bit to get used to. But, for the moment there aren't a lot of alternatives.

Andrew Reid
  • 37,021
  • 7
  • 64
  • 83
  • I was able to implement your solution, thanks for that! but now i'm stuck at implementing it with my own data. This is how i gather my data. – Tilou Feb 12 '19 at 09:44
  • d3.json("all.json", function (data) { console.log(data); // convert lat/lng to numeric data.forEach(function (d) { d.lat = +d.lat; d.lon = +d.lon; }); points = []; // x,y maps to lng,lat - ref[2] data.forEach(function (d) { d.lat = +d.lat; d.lon = +d.lon; var x = projection([d.lon, d.lat])[0]; var y = projection([d.lon, d.lat])[1]; points.push([x, y]); }); – Tilou Feb 12 '19 at 09:44