| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing the text Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l labels Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| Back | FazBrowse Home | New Git URL |
Lets Scatter lay its points out as a beeswarm: points with close values move apart along one axis instead of piling up, while their position on the other axis stays exact. We need it for a categories × counts bubble chart in our app, which today is a hand-rolled d3-force layout that moves the bubbles off their values and still leaves overlaps.
The options. spreadAxis: 'x' | 'y' turns the layout on; without it the component behaves exactly as before. spreadMax limits how far a point's edge can move from its position, in the domain units of spreadAxis: with categories at 0, 1, 2…, 0.45 keeps every swarm within 45% of the distance to the next category. spreadPadding is the gap between the points in pixels, 1 by default.
The layout is greedy, the idea behind Observable Plot's dodgeX/dodgeY transforms. Points are placed largest first, each at the free position closest to its own along the spread axis. Every placed neighbour blocks an interval of that axis; the intervals are merged and the nearest free position is found in one pass, so a point costs O(k log k) for its k neighbours. The result is deterministic, has no overlaps and keeps the values exact. We benchmarked d3-force first: with the value axis pinned it still leaves overlaps and is ~40× slower, and with it free it moves the points off their values — by 28px on average on the chart's real data.
When a swarm doesn't fit its spreadMax, or the plot area without it, all the points shrink by one common factor, the padding with them, so the sizes stay comparable across the chart. The factor is found by a binary search. The points shrink until the smallest of them is 1px across; past that they are clamped into their limits and may overlap.
Where it runs. The layout needs the final pixel ranges, which the container sets only after the components' bleed, so it runs in _render. It rebuilds the points first, so that repeated renders shrink the configured sizes rather than the shrunk ones. The offsets live on _point (xOffsetPx, yOffsetPx), and every place that turns a point into pixels — the points themselves, the label boxes, the label collisions — goes through getPointPosition. The room the edge swarms need is found in domain units in bleed: the range shrinks by that very bleed, so the extra domain past the ends takes extra / (domain length + extra) of it.
Categories. Pass them as indices and set xDomain: [-0.5, n - 0.5]: every category gets a slot one unit wide and stays in place whatever spreadMax is. Without it, the bleed above keeps the first and the last swarm on the chart, but the categories move towards the edges as spreadMax goes down.
Fitted central labels. Position.Center labels were sized as 0.7 · d / √length, which lets anything longer than about six characters stick out of the point — up to 1.26× the diameter in the example. The label is now measured once at a reference size in its own font, and its font size is chosen so that the label's box, the text width by one UNOVIS_TEXT_DEFAULT.lineHeight, fits 90% of the diameter. Single characters stay about where they were (~0.65 d instead of 0.7 d); long labels get smaller instead of overflowing. This changes how existing charts with central labels look.
Performance of the layout alone: 200 points that have to shrink take 6ms; 10 000 continuous values 76ms in a single pass; 2 000 points with long runs of equal values 79ms. The worst case we found, 4 000+ points sharing one value, takes ~0.6s.
Not in this PR, to discuss:
Fallback label positions: labelPosition accepting a list such as ['center', 'right', 'left'], the label going to the first position where it fits. It would replace the value threshold the example uses to choose between inside and on the right.
collideLabels checks a label against the other points with rectIntersect(…, 2), and that tolerance shrinks a rect by 2px on the left and top but by 4px on the right and bottom. A small neighbour point can cover the start of a label by ~5px unnoticed — visible in the spreadMax: 0.1 screenshot. This is how Scatter worked before; the dense swarms just make it show.
Crosshair snaps to the values of the points, not to their positions in the swarm.
Specs for the layout, the Scatter render (offsets, limits, bleed) and the label fit
Lint, @unovis/ts build with declarations
Angular wrapper regenerated
Dev examples: Spread Points, Spread Timeline
Docs: Scatter › Spreading Points
🤖 Generated with Claude Code