A more practical geographic use of a clustering module than my initial demonstration.
Each shipwreck is represented with a circle 2 px in radius. Overlapping circles are combined and new centroids are calculated based on a weighted average of their areas. Because of the wandering centroid, the merger of larger circles can result in the clearing of a neighborhood around larger circles - this effect is most noticeable when combining thousands of circles.
Scroll to zoom in/out. Drag to pan.
I'm working on developing the clusterer a bit more, but you can find some initial documentation and source on github.
Shipwreck data hosted by ESRI here.
xxxxxxxxxx
<meta charset="utf-8">
<style>
svg, canvas {
position: absolute;
top: 0px;
left: 0px;
}
rect {
fill: white;
}
</style>
<svg width="960" height="500"></svg>
<canvas width="960" height="500"></canvas>
<script src="https://d3js.org/d3.v5.min.js"></script>
<script src="https://d3js.org/d3-tile.v0.0.min.js"></script>
<script src="d3-cluster.js"></script>
<script>
// Canvas and SVG:
var canvas = d3.select("canvas");
var ctx = canvas.node().getContext("2d");
var svg = d3.select("svg");
// Basic parameters:
var width = +canvas.attr("width");
var height = +canvas.attr("height");
var tau = 2 * Math.PI;
var baseScale = 960/tau;
var center = [-75.5, 38];
// Tiles Projection Setup:
var tileProjection = d3.geoMercator()
.scale(1/tau)
.translate([0,0]);
var tileCenter = tileProjection(center);
// Geographic Projection Setup:
var geoProjection = d3.geoMercator()
.scale(baseScale)
.center(tileProjection.invert([0,0]))
.translate([0,0]);
// Set up tiles:
var tile = d3.tile()
.size([width, height]);
// Create a g for the tiles:
var raster = svg.append("g");
var controls = svg.append("g");
d3.csv("shipwrecks.csv").then(function(data) {
// Set up nodes:
resetNodes(data); // initialize
// Set up clusterer
var cluster = d3.cluster()
.nodes(data)
.on("tick", ticked);
// Set up zoom.
var zoom = d3.zoom()
.on("zoom",zoomed)
.scaleExtent([1 << 14, 1 << 17])
// Call the zoom:
canvas.call(zoom)
.call(zoom.transform, d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(1 << 15)
.translate(-tileCenter[0], -tileCenter[1]));
//
// Zoom Functionality:
//
function zoomed() {
// stop cluster:
cluster.stop();
// Scale values:
var kt = d3.event.transform.k; // tile scale factor
var k = kt/width; // projection scale factor
// Translate values:
var x = d3.event.transform.x
var y = d3.event.transform.y;
var t0 = geoProjection.translate();
var t = [x,y];
// Update the cluster:
// If translated only:
if(geoProjection.scale() == k * baseScale) {
// Update the projection:
geoProjection.translate([x,y])
clear();
// Adjust cluster nodes:
data.forEach(function(node) {
node.x += t[0] - t0[0];
node.y += t[1] - t0[1];
drawCircle(node);
})
}
// If scaled:
else {
// Update Projection:
geoProjection.translate([x,y])
geoProjection.scale(baseScale * k);
// Update the cluster:
resetNodes(data);
cluster.nodes(data)
.alpha(1)
}
cluster.restart();
// Update the tiles:
var tiles = tile
.scale(kt)
.translate([x, y])();
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 "https://server.arcgisonline.com/ArcGIS/rest/services/Ocean_Basemap/MapServer/tile/" + d[2] + "/" + d[1] + "/" + d[0] + ".png"; })
.attr("x", function(d) { return d[0] * 256; })
.attr("y", function(d) { return d[1] * 256; })
.attr("width", 256)
.attr("height", 256);
}
//
// Tick functionality
//
function ticked(x,y) {
clear();
// Update cluster nodes:
this.nodes(data.filter(function(d) { return d.r != 0; }))
// draw still active nodes:
data.filter(function(d) { return d.r != 0; })
.forEach(drawCircle)
attribute(); // tile attribution
}
})
// Helper functions:
function resetNodes(nodes) {
nodes.forEach(function(node) {
var p = geoProjection([+node.long,+node.lat]);
node.x = p[0];
node.y = p[1];
node.r = 2;
node.a = Math.PI * node.r * node.r;
node.collided = false;
node.count = 1;
})
}
function drawCircle(d) {
ctx.fillStyle = d.collided ? ( d.r > 20 ? "#a8ddb5" : "#43a2ca" ) : "#0868ac";
ctx.beginPath();
ctx.moveTo(d.x, d.y);
ctx.arc(d.x, d.y, d.r, 0, 2 * Math.PI);
ctx.fill();
if(d.r > 20) drawText(d);
}
function drawText(d) {
ctx.font = d.r / 3 + "px Arial";
ctx.textAlign = "center";
ctx.fillStyle = "white";
ctx.fillText(d.count,d.x,d.y+d.r/9);
}
function clear() {
ctx.clearRect(0, 0, width, height);
}
function attribute() {
ctx.font = "10px Arial";
ctx.textAlign = "left";
ctx.fillStyle = "black";
ctx.fillText("Tiles \u00A9 Esri - Sources: GEBCO, NOAA, CHS, OSU, UNH, CSUMB, National Geographic, DeLorme, NAVTEQ, and Esri", 4, height-4);
}
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>
https://d3js.org/d3.v5.min.js
https://d3js.org/d3-tile.v0.0.min.js