Skip to content

Instantly share code, notes, and snippets.

@veltman
Last active February 23, 2024 18:10
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save veltman/d041815cfc669fc647f0271bbd3ff307 to your computer and use it in GitHub Desktop.
Save veltman/d041815cfc669fc647f0271bbd3ff307 to your computer and use it in GitHub Desktop.
Grouped clustering

Point clustering with the constraint that points should only be clustered within borders.

  1. Use supercluster to cluster the points within each boundary.
  2. Use a force simulation to avoid collisions between points along the borders.
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
<!DOCTYPE html>
<meta charset="utf-8">
<style>
path {
fill: #f4f4f4;
stroke: #666;
stroke-width: 1px;
}
circle {
stroke: none;
}
text {
fill: #444;
font-size: 12px;
font-family: sans-serif;
text-anchor: middle;
}
</style>
<body>
<script src="//d3js.org/d3.v5.min.js"></script>
<script src="https://unpkg.com/supercluster@4.1.1/dist/supercluster.min.js"></script>
<script>
const width = 480,
height = 500;
const color = d3
.scaleOrdinal()
.range(["#ef9a9a", "#9fa8da", "#ffe082", "#80cbc4"]);
const projection = d3.geoMercator();
const path = d3.geoPath().projection(projection);
const svg = d3
.select("body")
.append("svg")
.attr("width", width * 2)
.attr("height", height);
Promise.all([
d3.json("four-corners.geo.json"),
d3.json("random-points.geo.json")
]).then(([states, points]) => {
projection.fitExtent([[10, 10], [width - 10, height - 10]], states);
const left = svg.append("g");
const right = svg.append("g").attr("transform", "translate(" + width + ")");
// Draw background
[left, right].forEach(g =>
g
.selectAll("path")
.data(states.features)
.enter()
.append("path")
.attr("d", path)
);
// Original points on left
left
.selectAll("circle")
.data(points.features)
.enter()
.append("circle")
.attr("r", 2)
.attr(
"transform",
d => "translate(" + projection(d.geometry.coordinates) + ")"
)
.attr("fill", d => color(d.properties.state));
// Get a flat list of clusters within each state
// Each one is GeoJSON plus x, y, and r properties
const clusters = getClusters(points.features);
// Draw the clusters
const clustered = right
.selectAll("g")
.data(clusters)
.enter()
.append("g")
.attr("transform", d => "translate(" + d.x + " " + d.y + ")");
clustered
.append("circle")
.attr("r", d => d.r)
.attr("fill", d => color(d.properties.state));
// Label the clusters
clustered
.append("text")
.text(d => d.properties.point_count || 1)
.attr("dy", "0.35em");
// Clusters on the border might overlap, nudge them apart with a collision force
d3.forceSimulation(clusters)
.force(
"collide",
d3
.forceCollide()
.strength(0.8)
.radius(d => d.r)
)
.on("tick", () =>
clustered.attr("transform", d => "translate(" + d.x + " " + d.y + ")")
);
});
function getClusters(points) {
const allClusters = [];
// Group points by state
const byState = d3
.nest()
.key(d => d.properties.state)
.entries(points);
// Cluster each group individually
byState.forEach(entry => {
const index = supercluster({
radius: 50,
maxZoom: 5
});
index.load(entry.values);
index.getClusters([-180, -90, 180, 90], 5).forEach(cluster => {
// Add x, y, r, and state properties to each cluster
const [x, y] = projection(cluster.geometry.coordinates);
cluster.properties.state = entry.key;
cluster.x = x;
cluster.y = y;
cluster.r = cluster.properties.point_count ? 14 : 10;
allClusters.push(cluster);
});
});
return allClusters;
}
</script>
Display the source blob
Display the rendered blob
Raw
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