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/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> |