Beeswarm, force layout
A beeswarm chart laid out by a d3-force simulation that runs to completion before the circles are drawn. For a beeswarm without a force layout, see this example. For another force layout, see the circle pack force example.
- +page.svelte
- ./_components/Key.html.svelte
- ./_components/AxisX.svelte
- ./_components/BeeswarmForce.svelte
- ./_data/us-senate.csv
<script>
import { LayerCake, Svg, Html } from 'layercake';
import { scaleOrdinal } from 'd3-scale';
import Key from './_components/Key.html.svelte';
import AxisX from './_components/AxisX.svelte';
import BeeswarmForce from './_components/BeeswarmForce.svelte';
// The CSV rows are parsed, and their numbers typed, by @rollup/plugin-dsv. See vite.config.js
import data from './_data/us-senate.csv';
const xKey = 'date_of_birth';
const cKey = 'gender';
const titleKey = 'name';
const r = 6;
const seriesColors = ['#ffcc00', '#000'];
// Keep just the fields the chart reads. The x value is the birth year, the
// first part of the date string.
const rows = data.map(d => ({
[titleKey]: d[titleKey],
[cKey]: d[cKey],
[xKey]: +String(d[xKey]).split('-')[0]
}));
</script>
<div class="chart-container">
<LayerCake
padding={{ bottom: 15 }}
x={xKey}
c={cKey}
cScale={scaleOrdinal()}
cRange={seriesColors}
cDomainSort={true}
data={rows}
>
<Svg>
<AxisX />
<BeeswarmForce
{r}
strokeWidth={1}
xStrength={0.95}
yStrength={0.075}
getTitle={d => d[titleKey]}
/>
</Svg>
<Html pointerEvents={false}>
<Key shape="circle" />
</Html>
</LayerCake>
</div>
<style>
/* Give the wrapper a width and height. LayerCake fills it. */
.chart-container {
width: 100%;
height: 250px;
}
</style><!--
@component
Generates an HTML key for an ordinal `c` scale, with one chip and label per domain value.
-->
<script>
import { getLayerCakeContext } from 'layercake';
const k = getLayerCakeContext();
/**
* @typedef {Object} Props
* @property {'circle'|'line'|'square'} [shape='square'] - The shape of each chip.
* @property {'start'|'center'|'end'} [align='start'] - Where the key sits in its box, as flexbox `justify-content`.
* @property {((d: any) => string)|Record<string, string>} [lookup] - Either a function that formats a value, or an object mapping values to labels. A value missing from the object is shown as is.
* @property {boolean} [capitalize=true] - Capitalize the first character of each label.
*/
/** @type {Props} */
let { shape = 'square', align = 'start', lookup, capitalize = true } = $props();
/** @param {any} val */
function capitalizeFirst(val) {
return String(val).replace(/^\w/, d => d.toUpperCase());
}
/** @param {any} val */
function displayName(val) {
if (lookup) {
return typeof lookup === 'function' ? lookup(val) : lookup[val] || val;
}
return capitalize === true ? capitalizeFirst(val) : val;
}
</script>
<div class="key" style="justify-content: {align === 'end' ? 'flex-end' : align};">
{#each k.cDomain as item}
<div class="key-item">
<div
class="chip chip__{shape}"
style="background: {shape === `line`
? `linear-gradient(-45deg, #ffffff 40%, ${k.cScale?.(item) ?? '#ccc'} 41%, ${k.cScale?.(item) ?? '#ccc'} 59%, #ffffff 60%)`
: (k.cScale?.(item) ?? '#ccc')};"
></div>
<div class="name">{displayName(item)}</div>
</div>
{/each}
</div>
<style>
.key {
display: flex;
}
.key-item {
margin-right: 14px;
}
.chip {
display: inline-block;
position: relative;
width: 12px;
height: 12px;
}
.chip__circle {
border-radius: 50%;
}
.chip__line:after {
content: '';
position: absolute;
border-width: 3px;
width: 14px;
transform: rotate(-45deg);
transform-origin: 14px 5px;
}
.name {
display: inline;
font-size: 14px;
text-shadow:
-1px -1px 0 #fff,
1px -1px 0 #fff,
-1px 1px 0 #fff,
1px 1px 0 #fff;
}
</style><!--
@component
Generates an SVG x-axis along the bottom of the chart. If the x scale is a band scale, each tick sits in the middle of its band.
-->
<script>
import { getLayerCakeContext } from 'layercake';
const k = getLayerCakeContext();
/**
* @typedef {Object} Props
* @property {boolean} [tickMarks=false] - Show a vertical mark at each tick.
* @property {boolean} [gridlines=true] - Show gridlines extending into the chart area.
* @property {number} [tickMarkLength=6] - The length of the tick mark.
* @property {boolean} [showBaseline=false] - Show a solid line along the bottom of the chart.
* @property {boolean} [snapLabels=false] - Instead of centering the text labels on the first and the last items, align them to the edges of the chart.
* @property {(d: any) => string} [format=d => d] - Formats a tick value for display.
* @property {number|Array<any>|((ticks: Array<any>) => Array<any>)} [ticks] - If this is a number, it passes that along to the [d3Scale.ticks](https://github.com/d3/d3-scale) function. If this is an array, hardcodes the ticks to those values. If it's a function, passes along the default tick values and expects an array of tick values in return. If nothing, it uses the default ticks supplied by the D3 function.
* @property {number} [tickGutter=0] - The gap in pixels between the bottom of the chart area and the start of the tick.
* @property {number} [dx=0] - Horizontal offset of the label in pixels.
* @property {number} [dy=12] - Vertical offset of the label in pixels.
*/
/** @type {Props} */
let {
tickMarks = false,
gridlines = true,
tickMarkLength = 6,
showBaseline = false,
snapLabels = false,
format = d => d,
ticks = undefined,
tickGutter = 0,
dx = 0,
dy = 12
} = $props();
// Snapped labels anchor the first tick to the left edge and the last to the right
/** @param {number} i */
function textAnchor(i) {
if (snapLabels === true) {
if (i === 0) {
return 'start';
}
if (i === tickVals.length - 1) {
return 'end';
}
}
return 'middle';
}
let tickLen = $derived(tickMarks === true ? (tickMarkLength ?? 6) : 0);
let isBandwidth = $derived(typeof k.xScale.bandwidth === 'function');
/** @type {Array<any>} */
let tickVals = $derived(
Array.isArray(ticks)
? ticks
: isBandwidth
? k.xScale.domain()
: typeof ticks === 'function'
? ticks(k.xScale.ticks())
: k.xScale.ticks(ticks)
);
let halfBand = $derived(isBandwidth ? k.xScale.bandwidth() / 2 : 0);
</script>
<g class="axis x-axis" class:snapLabels>
{#if showBaseline === true}
<line class="baseline" y1={k.height} y2={k.height} x1="0" x2={k.width} />
{/if}
{#each tickVals as tick, i (tick)}
<!-- Fall back to the chart height if the chart has no y dimension -->
<g
class="tick tick-{i}"
transform="translate({k.xScale(tick)},{k.yRange ? Math.max(...k.yRange) : k.height})"
>
{#if gridlines === true}
<line class="gridline" x1={halfBand} x2={halfBand} y1={-k.height} y2="0" />
{/if}
{#if tickMarks === true}
<line
class="tick-mark"
x1={halfBand}
x2={halfBand}
y1={tickGutter}
y2={tickGutter + tickLen}
/>
{/if}
<text x={halfBand} y={tickGutter + tickLen} {dx} {dy} text-anchor={textAnchor(i)}
>{format(tick)}</text
>
</g>
{/each}
</g>
<style>
.tick {
font-size: 11px;
}
line,
.tick line {
stroke: #aaa;
stroke-dasharray: 2;
}
.tick text {
fill: #666;
}
.tick .tick-mark,
.baseline {
stroke-dasharray: 0;
}
/* Push the snapped end labels 3px outward so they clear the chart edge */
.axis.snapLabels .tick:last-child text {
transform: translateX(3px);
}
.axis.snapLabels .tick.tick-0 text {
transform: translateX(-3px);
}
</style><!--
@component
Generates an SVG beeswarm chart, using a [d3-force simulation](https://github.com/d3/d3-force) to spread the circles apart.
-->
<script>
import { forceSimulation, forceX, forceY, forceCollide } from 'd3-force';
import { getLayerCakeContext } from 'layercake';
const k = getLayerCakeContext();
/**
* @typedef {Object} Props
* @property {number} [r=4] - The circle radius in pixels.
* @property {number} [strokeWidth=1] - The circle's stroke width in pixels.
* @property {string} [stroke='#fff'] - The circle's stroke color.
* @property {number} [xStrength=0.95] - How hard each circle is pulled toward its x value. See [forceX.strength](https://github.com/d3/d3-force#x_strength).
* @property {number} [yStrength=0.075] - How hard each circle is pulled toward the vertical middle of the chart. See [forceY.strength](https://github.com/d3/d3-force#y_strength).
* @property {(d: any) => string} [getTitle] - Returns hover text for a row, shown in a `<title>` tag.
*/
/** @type {Props} */
let {
r = 4,
strokeWidth = 1,
stroke = '#fff',
xStrength = 0.95,
yStrength = 0.075,
getTitle
} = $props();
// Run the simulation to the end up front so the circles come out settled.
// The simulation mutates its nodes, so it gets copies of the rows.
/** @type {Array<any>} */
let nodes = $derived.by(() => {
if (!k.width || !k.height || !k.data.length) return [];
const simulation = forceSimulation(k.data.map(d => ({ ...d })))
.force('x', forceX().x(k.xGet).strength(xStrength))
.force(
'y',
forceY()
.y(k.height / 2)
.strength(yStrength)
)
.force('collide', forceCollide(r))
.stop();
// Tick as many times as the simulation would on its own before it cools off
const iterations = Math.ceil(
Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())
);
for (let i = 0; i < iterations; i += 1) {
simulation.tick();
}
return simulation.nodes();
});
</script>
<g class="bee-group">
{#each nodes as node}
<circle
fill={k.cGet?.(node) ?? '#ccc'}
{stroke}
stroke-width={strokeWidth}
cx={node.x}
cy={node.y}
{r}
>
{#if getTitle}
<title>{getTitle(node)}</title>
{/if}
</circle>
{/each}
</g>state_name,party,name,gender,date_of_birth Alaska,R,Daniel Sullivan,male,1964-11-13 Alaska,R,Lisa Murkowski,female,1957-05-22 Alabama,D,Gordon Jones,male,1954-05-04 Alabama,R,Richard Shelby,male,1934-05-06 Arkansas,R,John Boozman,male,1950-12-10 Arkansas,R,Tom Cotton,male,1977-05-13 Arizona,D,Kyrsten Sinema,female,1976-07-12 Arizona,R,Martha McSally,female,1966-03-22 California,D,Dianne Feinstein,female,1933-06-22 California,D,Kamala Harris,female,1964-10-20 Colorado,R,Cory Gardner,male,1974-08-22 Colorado,D,Michael Bennet,male,1964-11-28 Connecticut,D,Christopher Murphy,male,1973-08-03 Connecticut,D,Richard Blumenthal,male,1946-02-13 Delaware,D,Christopher Coons,male,1963-09-09 Delaware,D,Thomas Carper,male,1947-01-23 Florida,R,Rick Scott,male,1952-12-01 Florida,R,Marco Rubio,male,1971-05-28 Georgia,R,David Perdue,male,1949-12-10 Georgia,R,Johnny Isakson,male,1944-12-28 Hawaii,D,Brian Schatz,male,1972-10-20 Hawaii,D,Mazie Hirono,female,1947-11-03 Iowa,R,Joni Ernst,female,1970-07-01 Illinois,D,Richard Durbin,male,1944-11-21 Idaho,R,James Risch,male,1943-05-03 Illinois,D,Tammy Duckworth,female,1968-03-12 Indiana,R,Todd Young,male,1972-08-24 Kansas,R,Jerry Moran,male,1954-05-29 Kansas,R,Pat Roberts,male,1936-04-20 Kentucky,R,Mitch McConnell,male,1942-02-20 Kentucky,R,Rand Paul,male,1963-01-07 Louisiana,R,Bill Cassidy,male,1957-09-28 Louisiana,R,John Kennedy,male,1951-11-21 Massachusetts,D,Edward Markey,male,1946-11-11 Massachusetts,D,Elizabeth Warren,female,1949-06-22 Maryland,D,Benjamin Cardin,male,1943-10-05 Maryland,D,Chris Van Hollen,male,1959-01-10 Maine,I,Angus King,male,1944-03-31 Maine,R,Susan Collins,female,1952-12-07 Michigan,D,Debbie Stabenow,female,1950-04-29 Michigan,D,Gary Peters,male,1958-01-01 Minnesota,D,Tina Smith,female,1958-03-04 Minnesota,D,Amy Klobuchar,female,1960-05-25 Missouri,R,Josh Hawley,male,1979-12-31 Indiana,R,Mike Braun,male,1954-03-24 Mississippi,R,Roger Wicker,male,1951-07-05 Mississippi,R,Cindy Hyde-Smith,female,1959-05-10 Iowa,R,Charles Grassley,male,1933-09-17 Montana,R,Steve Daines,male,1962-08-20 Idaho,R,Mike Crapo,male,1951-05-20 Missouri,R,Roy Blunt,male,1950-01-10 North Dakota,R,Kevin Cramer,male,1961-01-21 North Dakota,R,John Hoeven,male,1957-03-13 Montana,D,Jon Tester,male,1956-08-21 North Carolina,R,Richard Burr,male,1955-11-30 North Carolina,R,Thom Tillis,male,1960-08-30 Nebraska,R,Ben Sasse,male,1972-02-22 Nebraska,R,Deb Fischer,female,1951-03-01 New Hampshire,D,Jeanne Shaheen,female,1947-01-28 New Hampshire,D,Margaret Hassan,female,1958-02-27 New Jersey,D,Cory Booker,male,1969-04-27 New Jersey,D,Robert Menendez,male,1954-01-01 New Mexico,D,Martin Heinrich,male,1971-10-17 New Mexico,D,Tom Udall,male,1948-05-18 Nevada,D,Catherine Cortez-Masto,female,1964-03-29 Nevada,D,Jacky Rosen,female,1957-08-02 New York,D,Charles Schumer,male,1950-11-23 New York,D,Kirsten Gillibrand,female,1966-12-09 Ohio,R,Rob Portman,male,1955-12-19 Ohio,D,Sherrod Brown,male,1952-11-09 Oklahoma,R,James Inhofe,male,1934-11-17 Oklahoma,R,James Lankford,male,1968-03-04 Oregon,D,Jeff Merkley,male,1956-10-24 Oregon,D,Ron Wyden,male,1949-05-03 Pennsylvania,R,Patrick Toomey,male,1961-11-17 Pennsylvania,D,Robert Casey,male,1960-04-13 Rhode Island,D,Jack Reed,male,1949-11-12 Rhode Island,D,Sheldon Whitehouse,male,1955-10-20 South Carolina,R,Lindsey Graham,male,1955-07-09 South Carolina,R,Tim Scott,male,1965-09-19 South Dakota,R,John Thune,male,1961-01-07 South Dakota,R,Mike Rounds,male,1954-10-24 Tennessee,R,Marsha Blackburn,female,1952-06-06 Tennessee,R,Lamar Alexander,male,1940-07-03 Texas,R,John Cornyn,male,1952-02-02 Texas,R,Ted Cruz,male,1970-12-22 Utah,R,Mike Lee,male,1971-06-04 Utah,R,Mitt Romney,male,1947-03-12 Virginia,D,Mark Warner,male,1954-12-15 Virginia,D,Tim Kaine,male,1958-02-26 Vermont,I,Bernard Sanders,male,1941-09-08 Vermont,D,Patrick Leahy,male,1940-03-31 Washington,D,Maria Cantwell,female,1958-10-13 Washington,D,Patty Murray,female,1950-10-11 Wisconsin,R,Ron Johnson,male,1955-04-08 Wisconsin,D,Tammy Baldwin,female,1962-02-11 West Virginia,D,Joe Manchin,male,1947-08-24 West Virginia,R,Shelley Capito,female,1953-11-26 Wyoming,R,John Barrasso,male,1952-07-21 Wyoming,R,Michael Enzi,male,1944-02-01