Beeswarm Plot Chart — Collision-Free Dot Distribution SVG

Beeswarm Plot Chart · Charts · Plain HTML, CSS & JS · Live preview

What's included

Features

Greedy collision-avoidance algorithm computes every dot's vertical offset from scratch, not a canned formula
True 2D Euclidean distance collision checks, not just x-axis proximity, for visually correct packing
Points sorted by x position before placement so the algorithm always resolves collisions left to right
A bounded laneLimit safety valve guarantees the algorithm always terminates even in extremely dense clusters
Dashed mean line per group, readable directly against the visible spread of individual points around it
Every dot remains an individually hoverable data point with an exact-value tooltip
Box-Muller-generated realistic per-group sample data for the built-in demo, swappable for real datasets
Shared x-axis scale across all groups makes cluster position and width directly comparable

About this UI Snippet

Beeswarm Plot Chart — Greedy Collision-Avoidance Layout for One-Dot-Per-Data-Point Distributions

Screenshot of the Beeswarm Plot Chart snippet rendered live

A scatter plot along a single axis runs into a hard visual problem the moment two values land close together: the dots overlap and hide each other, silently erasing the very density information the chart exists to show. A beeswarm plot solves this by nudging each dot sideways, perpendicular to the axis, just enough to avoid touching its neighbors — every individual data point stays visible as its own circle, and the resulting cluster width becomes a direct, honest visual encoding of how many points fall near that value. This snippet renders five marathon age-group finish-time distributions this way, with the swarm positions computed by a real collision-avoidance algorithm rather than a canned layout.

Why this needs an actual layout algorithm, not just a formula

Unlike a bar or line chart, where every mark's position follows directly from a value and a scale, a beeswarm's *y*-offset for any given dot depends on every other dot already placed near it on the x-axis — there is no closed-form formula, only an iterative placement process. layoutSwarm() implements a greedy version of that process: points are sorted by their x position first, so the algorithm always considers dots left to right, then each point is tested at its lane's center y; if that position collides with any already-placed point within DIAMETER distance, it steps outward in alternating directions (dir *= -1) — first slightly above center, then slightly below, then further above, and so on — until it finds a position with no collision.

Collision distance, not just x-difference

The collision check computes real 2D Euclidean distance (Math.sqrt(dx*dx + dy*dy)) between a candidate point and every already-placed point, not just how close their x-values are. This matters because two points can have very different x positions yet still be close enough on the combined x/y plane to visually collide once one of them has already been offset vertically — checking true distance, rather than only x-proximity, is what keeps the packing visually correct rather than merely "close enough."

A safety valve against infinite packing

In a genuinely dense cluster, there may not be room within a lane to place every point without overlap no matter how far the algorithm searches. Each lane defines a laneLimit — a maximum vertical offset from center — and once a point's search offset exceeds it, the algorithm accepts that position and moves on rather than searching forever. This trades a small amount of overlap in the very densest regions for a layout that always terminates in a bounded number of steps.

Reading a mean line against the swarm itself

A dashed vertical line marks each group's arithmetic mean directly through its lane. Because the dots themselves are still individually visible, that mean line can be read *against* the actual spread of raw points around it — whether the mean sits in the thick of a dense cluster or off to one side of a skewed distribution — a comparison a single summary statistic alone could never show.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how the greedy collision-avoidance algorithm decides where to place each point, why it sorts points by x position before placing them, and what the laneLimit safety valve is protecting against in a very dense cluster. It's also a good candidate for extension — ask it to add a toggle between separate per-group lanes and one combined swarm with color-coded groups, animate points settling into position with a staggered transition when the dataset changes, or optimize the collision check with a spatial index (like a grid or k-d tree) so the layout stays fast on datasets with several thousand points.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a beeswarm plot chart in plain HTML, CSS, and JavaScript using inline SVG created with createElementNS — no charting library, no canvas.

Requirements:
- For at least four groups of raw numeric sample data, position each group's points along a shared x-axis scale, arranged in horizontal lanes (one lane per group) stacked vertically.
- Implement a real collision-avoidance placement algorithm (not a random jitter): sort each group's points by their x position, then for each point in order, try placing it at its lane's vertical center first; if that position is within a minimum distance of any already-placed point in the same lane (checked using true 2D Euclidean distance between the candidate position and each placed point, not just difference in x), search alternating positions above and below center in expanding steps until a collision-free position is found.
- Include a safety limit on how far a point may be pushed from its lane's center; if the collision-free search would exceed that limit, accept the current position anyway rather than searching indefinitely, so the algorithm always terminates in a bounded number of steps even for a very dense cluster of points.
- Draw each point as a small SVG circle in its group's color, individually hoverable with a tooltip showing its exact underlying value.
- Draw a dashed vertical line through each lane at that group's mean value, positioned using the same x-axis scale as the points themselves.
- Draw a shared x-axis with gridlines and labeled tick values beneath the lanes, plus a text label naming each group to the left of its lane.
- Include a data-generation helper using a Box-Muller transform to produce realistic pseudo-random sample data per group with a configurable mean, spread, and sample count, for demonstration purposes.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Source Code

<div class="app">
  <div class="card">
    <div class="card-header">
      <h3>Marathon Finish Times by Age Group</h3>
      <p class="sub">Every dot is one runner. Dots are packed sideways to avoid overlap, so density along the axis is visible directly instead of being hidden by overplotting.</p>
    </div>
    <div class="chart-wrap">
      <svg id="swarm" viewBox="0 0 640 380" xmlns="http://www.w3.org/2000/svg"></svg>
    </div>
    <div class="swarm-tooltip" id="swarmTip"></div>
  </div>
</div>

Step by step

How to Use

  1. 1
    Read cluster width as densityWhere a lane's dots bunch wide (top to bottom), many runners finished near that time; where dots stay near the center line, times were more spread out or sparse.
  2. 2
    Hover any dotHover a single dot to see a tooltip with its exact group and finish time — every dot is one real data point, not an aggregate.
  3. 3
    Compare the dashed mean linesEach lane's dashed vertical line marks that group's average finish time — compare it against the visible spread of dots around it.
  4. 4
    Swap in real dataReplace the genValues() calls in the GROUPS array with your own raw arrays of numeric values — layoutSwarm() works on any array directly.
  5. 5
    Adjust dot size and spacingChange the RADIUS constant in the JS panel — a smaller radius lets more points pack into the same lane before the laneLimit safety valve engages.
  6. 6
    Widen or narrow the lanesAdjust laneH (derived from the SVG height and group count) or laneLimit's multiplier to control how far dots are allowed to spread vertically.

Real-world uses

Common Use Cases

Race, test, or benchmark result distributions
Show every individual finish time, score, or benchmark run as its own point, revealing clustering and outliers a box plot alone would hide.
Survey and demographic response spread
Visualize how individual responses distribute across groups without collapsing them into a single summary statistic per group.
Scientific and clinical trial data
A standard technique for showing every individual measurement across treatment groups in research and clinical reporting.
Teaching collision-avoidance layout algorithms
A concrete, readable implementation of greedy point-packing, useful as a companion to the Violin Plot Chart for comparing distribution-visualization techniques.
Reference for from-scratch layout algorithms in SVG
The layoutSwarm() function is small, dependency-free, and reusable in any project needing one-dot-per-value distribution visualization without a charting library.
Related: Streamgraph Chart
See the Streamgraph Chart for a related charts pattern worth pairing with this one.
Related: Wind Rose Chart
See the Wind Rose Chart for a related charts pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

Each point is placed by a greedy algorithm that first tries the lane's vertical center, then checks the true 2D distance to every already-placed point near it on the x-axis. If that distance is smaller than the dot diameter (a collision), the algorithm tries a slightly different vertical offset, alternating above and below center in expanding steps, until it finds a position with no collision.

Processing points left to right means every collision check only ever needs to consider points that have already been placed and committed to a final position — there is no need to revisit or shift earlier points once a later one is placed, which keeps the algorithm a single pass rather than requiring iterative relaxation.

Each lane defines a maximum vertical offset (laneLimit). If a point's search for a collision-free position would exceed that limit, the algorithm accepts the current position anyway and moves on, accepting a small amount of visual overlap in the very densest regions rather than searching indefinitely or growing the lane without bound.

A jitter plot adds a small random vertical offset to each point purely to reduce visual overlap, with no guarantee that any two points won't still collide. A beeswarm plot computes each point's offset deliberately through collision detection, so points pack as tightly as possible without touching, which produces a more accurate visual read of local density.

Yes — replace the genValues() call for any group in the GROUPS array with your own array of raw numeric values. The layoutSwarm() function works directly on any array of numbers regardless of how it was produced.

Yes. Use the JSX, Vue, Angular, or Tailwind export buttons on this page. In React, run layoutSwarm() per group during render (memoized with useMemo, since it is roughly O(n squared) in the worst case for a dense cluster) and render the resulting circles from the computed positions.