Skip to content

Instantly share code, notes, and snippets.

@r-suen
Last active March 13, 2022 23:49
Show Gist options
  • Save r-suen/bc74cf986e9198daaf6d9ee32e93313b to your computer and use it in GitHub Desktop.
Save r-suen/bc74cf986e9198daaf6d9ee32e93313b to your computer and use it in GitHub Desktop.
D3 + supercluster

Clustering points using D3 and supercluster. Red circles are clusters with point counts. Green circles are individual points. Zoom in and out to see the clustering effect.

Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>D3 + supercluster</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/3.0.0/topojson.min.js"></script>
<script src="https://unpkg.com/supercluster@7.1.2/dist/supercluster.min.js"></script>
</head>
<style>
body {
margin: 0;
overflow: hidden;
}
rect {
fill: #fff;
}
.cluster {
fill: #ff6666;
stroke: #ff0000;
stroke-width: 0;
}
.cluster-label {
fill: #000;
dominant-baseline: middle;
text-anchor: middle;
font-family: sans-serif;
font-size: 14px;
}
.point {
fill: #17677b;
stroke: #f8f3f6;
stroke-width: 2;
}
</style>
<body>
<div id="map"></div>
<script>
const style = {
countriesColor: "#555",
countriesStrokeColor: "#ddd",
countriesStrokeWidth: 1,
clusterRadius: 20,
clusterStrokeWidth: 0,
pointRadius: 8,
pointStrokeWidth: 2,
};
const convertZoomLevelToMercator = (zoomLevel) => (2 ** 8 + zoomLevel) / 2 / Math.PI;
const convertZoomLevelFromMercator = (zoomLevelInMercator) =>
Math.log(zoomLevelInMercator * 2 * Math.PI) / Math.LN2 - 8;
const width = window.innerWidth;
const height = window.innerHeight;
const bbox = [-180, -85, 180, 85];
let prevZoom = 1;
const index = new Supercluster({
radius: 50,
maxZoom: 14,
});
const svg = d3.select("#map").append("svg").attr("width", width).attr("height", height);
svg.append("rect").attr("width", width).attr("height", height);
const g = svg.append("g");
const projection = d3
.geoMercator()
.scale(convertZoomLevelToMercator(1)) // 256px tile equivalent to zoom level 1
.translate([width / 2, height / 2]);
const path = d3.geoPath(projection);
const drawClusters = (zoomLevel) => {
const data = index.getClusters(bbox, zoomLevel - 1); // subtract 1 zoom level based on mapbox 512px tile
const clusters = g.selectAll("clusters").data(data).enter();
clusters
.append("circle")
.attr("cx", (d) => projection(d.geometry.coordinates)[0])
.attr("cy", (d) => projection(d.geometry.coordinates)[1])
.attr("r", (d) => (d.properties.cluster ? style.clusterRadius : style.pointRadius))
.attr("class", (d) => (d.properties.cluster ? "cluster" : "point"));
clusters
.append("text")
.attr("x", (d) => projection(d.geometry.coordinates)[0])
.attr("y", (d) => projection(d.geometry.coordinates)[1])
.text((d) => d.properties.point_count_abbreviated || null)
.attr("class", (d) => (d.properties.cluster ? "cluster-label" : "point-label"));
};
const zoomed = () => {
const t = d3.event.transform;
const currentZoom = Math.floor(convertZoomLevelFromMercator(t.k * projection.scale()));
if (currentZoom !== prevZoom) {
d3.selectAll("circle").remove();
d3.selectAll("text").remove();
drawClusters(currentZoom);
prevZoom = currentZoom;
}
g.attr("transform", t);
d3.selectAll("path").attr("stroke-width", style.countriesStrokeWidth / t.k);
d3.selectAll("circle")
.attr("r", (d) => (d.properties.cluster ? style.clusterRadius / t.k : style.pointRadius / t.k))
.style("stroke-width", (d) =>
d.properties.cluster ? style.clusterStrokeWidth / t.k : style.pointStrokeWidth / t.k
);
d3.selectAll("text").style("font-size", 14 / t.k);
};
const zoom = d3
.zoom()
.scaleExtent([0.5, 2 ** 10]) // 256px tile equivalent to zoom range 0-11
.on("zoom", zoomed);
svg.call(zoom);
d3.json("world-110m.json", (error, data) => {
if (error) throw error;
g.selectAll("path")
.data(topojson.feature(data, data.objects.countries).features)
.enter()
.append("path")
.attr("d", path)
.attr("fill", style.countriesColor)
.attr("stroke", style.countriesStrokeColor)
.attr("stroke-width", style.countriesStrokeWidth);
d3.json("amtrak_stations.geojson", (err, d) => {
if (err) throw err;
index.load(d.features);
drawClusters(1);
});
});
</script>
</body>
</html>
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment