Label placement
Eunoia’s label placer takes a decomposed diagram plus the dimensions
of each label and hands back a position per region. Labels that fit
inside their region anchor at the region’s pole of inaccessibility
(POI); labels that don’t fit are placed outside the diagram by the
exterior solver of your choice, with a tether so you can draw a
leader line back to the region.
The placer works in the same coordinate frame as the fitted diagram and never needs to know anything about your fonts or text metrics; you measure the labels, and it places them.
Mental model
Call place_labels (Rust)/placeLabelsForRegions (npm) with a
decomposed diagram and a { combination → (w, h) } map. You get a LabelPlacement per region: interior when the box fits, exterior
otherwise. The returned kind tells the renderer which branch
fired:
interior: the box fits; anchor sits at the region’s POI.exteriorRaycast: anchored outside the diagram bbox along the ray from the diagram centroid through the region’s POI.exteriorForceDirected: initialized from the raycast position, then iteratively settled against label-vs-label collisions and foreign-region polygon repulsion.
For exterior kinds, placement.tether points back into the region
so you can draw a leader line from the label to the region. By
default the tether is the region’s pole of inaccessibility (deep inside the polygon), safe for any rendering style,
including stroke-less fills, because the leader endpoint sits well
inside the visible colored area. Set strategy.tether = 'boundary' to attach it at the point where the outgoing ray exits
the polygon’s outer ring instead; the rendered leader then starts
on the polygon edge, matching the standard labeling convention.
Recommended when your renderer draws shape strokes.
On the label side, the placer returns placement.leaderEnd—the
point on the label’s bounding box where the leader should
terminate. Draw the leader from tether to leaderEnd (rather
than to anchor, which is the box center) so the line stops at
the box edge instead of running through the rendered text. The box
used for clipping is the size you passed in for that region, so
any half-gap padding the caller adds (see the gotchas below) is
preserved as the visible gap between the leader tip and the text.
Independently of the tether choice, both exterior solvers run a leader-vs-interior-label avoidance pass: when an exterior label’s leader segment would visually cross another region’s interior label, the exterior anchor is pushed tangentially until the segment clears. This is always on (no opt-out) because crossings are visually wrong and the push is purely tangential (it doesn’t change which side of the diagram the label ends up on).
Size measurement contract
You measure, eunoia places. Eunoia has no font or text metric
knowledge; it accepts label dimensions (w, h) in the same
coordinate units as the fitted shapes and tests whether an
axis-aligned rectangle of those dimensions inscribes inside the
region. How you measure is up to your renderer:
| Host | Typical measurement |
|---|---|
| SVG (DOM) | Render hidden <text> then getBBox(). |
| Canvas | ctx.measureText(label) (width) + font size (height). |
R grid | grid::convertWidth(grid::grobWidth(grob), "native") and friends. |
Python matplotlib | Renderer’s get_window_extent(...).transformed(ax.transData.inverted()). |
Sizes are keyed by the canonical combination string:
- A single-set region is its set name:
"A". - An intersection joins set names with
&in canonical (sorted) order:"A&B","A&B&C". - The complement region (universe minus all sets, present when
you fit with
complement: N) keys as the empty string:"".
Resize loop (size → place → measure)
Label sizes in user coordinates depend on the rendered canvas size (the ratio of font px to canvas px). For diagrams where labels land exterior, the canvas extent in turn depends on label sizes—labels stretch the bbox out. The fixed point typically converges in one to three iterations.
Native Rust: place_labels_to_fixed_point
use eunoia::plotting::{place_labels_to_fixed_point, PlacementStrategy};
let placements = place_labels_to_fixed_point(
®ions,
container.as_ref(),
initial_sizes,
&PlacementStrategy::default(),
|bbox| measure(bbox), // your closure: bbox → sizes
1e-3, // bbox tolerance (relative)
8, // max iterations
); JavaScript/TypeScript
The npm wrapper doesn’t ship a fixed-point helper because passing a JS closure across the WASM boundary buys nothing; drive the loop in JS:
import { placeLabelsForRegions, placementsBbox } from "@jolars/eunoia";
let sizes = measureLabels(initialBbox);
let placements = {};
for (let i = 0; i < 8; i++) {
placements = placeLabelsForRegions({ regions, container, sizes });
const bbox = placementsBbox({ placements, sizes });
if (!bbox) break;
const newSizes = measureLabels(bbox);
if (closeEnough(sizes, newSizes)) break;
sizes = newSizes;
} R/Python (sketch)
Same shape as the JS loop: the host language drives place → placements_bbox → re-measure until the canvas short side stops
moving.
sizes <- measure_labels(initial_bbox)
for (i in 1:8) {
placements <- place_region_labels(regions, container, sizes)
bbox <- placements_bbox(placements, sizes)
new_sizes <- measure_labels(bbox)
if (close_enough(sizes, new_sizes)) break
sizes <- new_sizes
} Strategy knobs
The leader strategy ties the edge type to the placement
algorithm that suits it. Today the only edge type is straight (leader: { type: 'straight', placement: 'raycast' | 'forceDirected' });
omit leader entirely for the default, straight leaders placed by
raycasting. The placement chooses how exterior labels are positioned:
raycastplacement (default): deterministic ray from the diagram centroid through the region’s POI; anchor lands outside the union polygon of the fitted shapes (or container, if you set one), padded bymargin. Clearance is checked per-vertex with the label’s full footprint, so width and height are honored even on diagonal rays. It is cheap and predictable.forceDirectedplacement: warm-starts from the raycast positions, then iterates a soft spring (back to home) plus three repulsions: label-vs-label, label-vs-union-polygon containment along the raycast direction, and label-vs-foreign-region polygon repulsion. Default cap is 200 outer iterations.
Pick raycast for typical n=2..3 diagrams or any case where labels
naturally spread around the perimeter. Use forceDirected when raycast labels visually overlap each other or land on top of
unrelated regions, for example dense n=4 ellipse diagrams where
several intersection regions push their labels outward at similar
angles. (d3-style elbow leaders, with their own placement
algorithm, are planned.)
The two examples below are the same n=4 spec under each strategy. Drag the page wider to see them side by side; the difference is visible mostly when labels need exterior placement.
Strategy knobs (defaults in parentheses). margin and iterations live on the leader object alongside type/placement; the rest
sit on the strategy itself:
leader.margin(per-label proportional: `0.5 max(w, h))*: padding between the diagram bbox/container and the label. The default scales with each label's own size, so a long string likeA&B&C&Dends up further out than a short one. Override with a fixed value (e.g.margin: 2`) when you want every exterior label flush to the diagram regardless of length.leader.iterations(200,forceDirectedonly): outer iteration cap; inner label-vs-label sweep loops to disjointness so spring re-overlap doesn’t leak into the converged result.precision(0.01): polylabel-style search precision for the interior fit-check; smaller is more accurate, costlier.tether('poi'): where the exterior leader tether attaches on the source region.'poi'(default) keeps the tether at the region’s pole of inaccessibility, safe for any rendering style.'boundary'attaches at the point where the outgoing ray exits the region’s outer polygon ring, so the rendered leader starts on the polygon edge; recommended when your renderer draws shape strokes. Falls back to the POI if no ray-vs-edge intersection is found.leaderGap(0): visible gap (in the same coordinate units as the label sizes) between the leader tip (placement.leaderEnd) and the label’s bounding box. The placer inflates the box used to computeleaderEndby this amount on every side, so the leader stopsleaderGapunits short of the rendered text edge. Negative values are clamped to0. Set this when you hand raw measured bboxes to the placer and want breathing room between the leader tip and the glyphs; leave at0when you already pre-pad sizes for the label-vs-label gap (the same padding then acts as the leader gap automatically).
Rendering recipe
Pattern-match on placement.kind. Interior placements just need an
anchor; exterior placements also need a leader line from placement.tether (the region’s POI by default, or the polygon
exit point when strategy.tether = 'boundary') to placement.leaderEnd (the point on the label’s bounding box where
the leader should terminate, so it doesn’t run through the text).
Draw the leader as the polyline tether → leaderWaypoints… → leaderEnd; straight leaders carry no waypoints, so it collapses to
a single segment:
for (const region of regions) {
const p = placements[region.combination];
if (!p) continue;
if (p.kind === "exteriorRaycast" || p.kind === "exteriorForceDirected") {
drawPolyline([p.tether!, ...p.leaderWaypoints, p.leaderEnd!]); // clipped at the box edge
}
drawLabel(p.anchor, region.combination);
} The web app’s DiagramSvg.svelte is the canonical SVG version; see the leader-line block around
line 575 and the placementsBbox canvas-extent block around line
100 for a complete reference renderer.
A few gotchas worth knowing:
- Complement region key is the empty string. When fitting with
complement: N, the regions map includes acombination === ""entry whose pieces are the container minus the union of fitted shapes. Provide a size for""if you want to label it; omit it to leave the complement unlabeled. - Canvas extent depends on label sizes. Use
placementsBbox(orplacements_bboxin Rust) to extend your viewBox/canvas to cover exterior labels; without it, exterior anchors land off-screen. - The placer separates labels to non-overlap, not to a visible
gap. If you want breathing room between adjacent labels (and
you usually do), pad the sizes you pass in by your desired
half-gap on every side, then render text at the original
measured size. The two examples above use a 1-user-unit per-side
pad for visible spacing. The same padding also shows up as the
visible gap between the leader tip (
placement.leaderEnd) and the rendered text, sinceleaderEndsits on the padded box edge. If you hand the placer raw measured bboxes instead of pre-padded ones, usestrategy.leaderGapto introduce the leader-side gap independently; it only affectsleaderEnd, not label-vs-label separation. - The interior fit-check uses a radial-conservative bound. It never returns false positives (the inscribed rectangle is always strictly inside the region), but it can return false negatives for highly anisotropic regions. A tighter directional-clearance solver is on the roadmap.