Beeswarm.html.svelte component
Generates an HTML beeswarm chart. Each circle sits as low as it can without overlapping its neighbours.
| Param | Default | Required | Description |
|---|---|---|---|
r number |
3 |
The circle radius in pixels. | |
strokeWidth number |
0 |
The circle's stroke width in pixels. | |
stroke string |
'#fff' |
The circle's stroke color. | |
spacing number |
1.5 |
Whitespace between circles in pixels. | |
getTitle (d: any) => string |
None | Returns hover text for a row, shown in a .title div on hover. |
<!--
@component
Generates an HTML beeswarm chart. Each circle sits as low as it can without overlapping its neighbours.
-->
<script>
import { getLayerCakeContext } from 'layercake';
const k = getLayerCakeContext();
/**
* @typedef {Object} Props
* @property {number} [r=3] - The circle radius in pixels.
* @property {number} [strokeWidth=0] - The circle's stroke width in pixels.
* @property {string} [stroke='#fff'] - The circle's stroke color.
* @property {number} [spacing=1.5] - Whitespace between circles in pixels.
* @property {(d: any) => string} [getTitle] - Returns hover text for a row, shown in a `.title` div on hover.
*/
/** @type {Props} */
let { r = 3, strokeWidth = 0, stroke = '#fff', spacing = 1.5, getTitle } = $props();
/**
* Place the circles left to right, pushing each one up only as far as it
* needs to clear the circles already placed. Adapted from Mike Bostock's
* beeswarm notebook: https://observablehq.com/@d3/beeswarm
* @param {Array<any>} data The rows to place.
* @param {{ minDistance?: number, x?: (d: any) => number }} [options] `minDistance` is the smallest centre-to-centre gap two circles may have. `x` gives a row its x position.
*/
function dodge(data, { minDistance = 1, x = d => d } = {}) {
const minDistanceSquared = minDistance ** 2;
// Each circle keeps its original row on `data`. Read any field you need
// from there. Copying fields onto the circle would only work when the
// accessor is a string, not a function.
/** @type {Array<any>} */
const circles = data.map(d => ({ x: x(d), data: d })).sort((a, b) => a.x - b.x);
const epsilon = 1e-3;
/** @type {any} */
let head = null;
/** @type {any} */
let tail = null;
// Returns true if a circle at (x, y) overlaps any circle in the queue.
/** @param {number} x @param {number} y */
function intersects(x, y) {
let a = head;
while (a) {
if (minDistanceSquared - epsilon > (a.x - x) ** 2 + (a.y - y) ** 2) {
return true;
}
a = a.next;
}
return false;
}
// Place each circle sequentially.
for (const b of circles) {
// Remove circles from the queue that can't overlap the new circle b.
while (head && head.x < b.x - minDistanceSquared) head = head.next;
// Find the lowest spot where b touches a circle in the queue without overlapping any.
if (intersects(b.x, (b.y = 0))) {
let a = head;
b.y = Infinity;
do {
let y = a.y + Math.sqrt(minDistanceSquared - (a.x - b.x) ** 2);
if (y < b.y && !intersects(b.x, y)) b.y = y;
a = a.next;
} while (a);
}
// Add b to the queue.
b.next = null;
if (head === null) head = tail = b;
else {
tail.next = b;
tail = b;
}
}
return circles;
}
let circles = $derived(dodge(k.data, { minDistance: r * 2 + spacing + strokeWidth, x: k.xGet }));
</script>
<div class="bee-group">
{#each circles as d}
<div
class="bee"
style="
background:{k.cGet?.(d.data) ?? '#ccc'};
border-color:{stroke};
border-width:{strokeWidth}px;
left:{d.x}px;
top:{k.height - r - spacing - strokeWidth / 2 - d.y}px;
width:{r * 2}px;
height:{r * 2}px;
"
>
{#if getTitle}
<div class="title">{getTitle(d.data)}</div>
{/if}
</div>
{/each}
</div>
<style>
.bee {
position: absolute;
border-style: solid;
border-radius: 50%;
transform: translate(-50%, -50%);
}
.title {
display: none;
white-space: nowrap;
padding: 0 3px;
border-radius: 3px;
font-size: 12px;
pointer-events: none;
position: absolute;
top: -15px;
left: 5px;
z-index: 9999;
}
.bee:hover .title {
display: block;
}
</style>