Point clustering with the constraint that points should only be clustered within borders.
- Use supercluster to cluster the points within each boundary.
- Use a force simulation to avoid collisions between points along the borders.
Point clustering with the constraint that points should only be clustered within borders.
<!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/[email protected]/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> |