Published
Edited
Sep 13, 2021
6 forks
Importers
76 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
data = FileAttachment("dataset.json").json()
Insert cell
circles = swarm(
data,
// map each data point to an x position (in pixels)
d => d.x * (chartWidth - 2 * pad),
// map each data point to a radius (in pixels)
d => 1.5 * d.r,
// map each data point to a priority which determines placement order (lower = earlier).
priority
)
Insert cell
swarm = (data, x, r, priority, symmetric = true) => {
let circles = data
.map((d, index) => ({
datum: d,
x: x(d),
y: Infinity,
r: r(d),
priority: priority(d),
index
}))
.sort((a, b) => d3.ascending(a.x, b.x));
let indices = d3
.range(0, circles.length)
.sort((a, b) => d3.ascending(circles[a].priority, circles[b].priority));
indices.forEach((index, order) => (circles[index].order = order));

for (let d of circles) {
if (d.x == undefined)
throw new Error('x is undefined for datum at index ' + d.index);
if (d.r == undefined)
throw new Error('r is undefined for datum at index ' + d.index);
if (d.priority == undefined)
throw new Error('priority is undefined for datum at index ' + d.index);
}
let { sqrt, abs, min } = Math;
let maxRadius = d3.max(circles, d => d.r);
for (let index of indices) {
let intervals = [];
let circle = circles[index];
// scan adjacent circles to the left and right
for (let step of [-1, 1])
for (let i = index + step; i > -1 && i < circles.length; i += step) {
let other = circles[i];
let dist = abs(circle.x - other.x);
let radiusSum = circle.r + other.r;
// stop once it becomes clear that no circle can overlap us
if (dist > circle.r + maxRadius) break;
// don't pay attention to this specific circle if
// it hasn't been placed yet or doesn't overlap us
if (other.y === Infinity || dist > radiusSum) continue;
// compute the distance by which one would need to offset the circle
// so that it just touches the other circle
let offset = sqrt(radiusSum * radiusSum - dist * dist);
// use that offset to create an interval in which this circle is forbidden
intervals.push([other.y - offset, other.y + offset]);
}
// We're going to find a y coordinate for this circle by finding
// the lowest point at the edge of any interval where it can fit.
// This is quadratic in the number of intervals, but runs fast in practice due to
// fact that we stop once the first acceptable candidate is found.
let y = 0;
if (intervals.length) {
let candidates = intervals
.flat()
.sort((a, b) => d3.ascending(abs(a), abs(b)));
for (let candidate of candidates) {
if (!symmetric && candidate > 0) continue;
if (intervals.every(([lo, hi]) => candidate <= lo || candidate >= hi)) {
y = candidate;
break;
}
}
}
circles[index].y = y;
}
return circles;
}
Insert cell
pad = 25
Insert cell
chartHeight = 300
Insert cell
html`<style>svg text { transition: fill-opacity 0.25s ease-out; } svg:hover text { fill-opacity: 1; }`
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more