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.exteriorMatched: placed on a ring that hugs the diagram silhouette, at its own outward angle, with angles spread in proportion to each label’s width and a final 2-opt uncrossing pass, so the straight leaders are provably non-crossing and stay close to the diagram.
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.
You only need the loop when both conditions hold: your font size is fixed in
pixels or points (so a label’s user-coordinate footprint is font_px / scale,
and scale moves when the bbox does), and at least one label lands exterior
(interior labels sit at their POI and never stretch the bbox). If either fails,
measure once and place once. Two common cases skip the loop entirely: fonts
expressed in the diagram’s own coordinate units make label sizes scale-invariant
(this is what the web app does—getBBox() inside the SVG viewBox already
returns user-coordinate sizes), and all-interior diagrams never move the bbox. A
pixel-fixed host like matplotlib does want the loop, because its points → data coordinates conversion depends on the axes limits, which in turn
depend on where exterior labels land.
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. The edge type is straight (leader: { type: 'straight', placement: 'raycast' | 'forceDirected' | 'matched' })
or d3-style elbow (orthogonal leaders with a column-based placement of their
own); omit leader entirely for the default, straight leaders placed by
raycasting. For straight leaders 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.matchedplacement: boundary-labeling. Each label sits on a ring that hugs the diagram silhouette, at its own outward (POI) angle; the angles are then spread proportionally to each label’s width so neighbouring boxes don’t overlap, and a final 2-opt pass uncrosses any crossing leaders. The uncrossing makes the leaders provably non-crossing, and the silhouette ring plus proportional spreading keeps the labels close to the diagram— directly targeting the two raycast failure modes (leaders that cross, labels that wander far). The ring grows only when a diagram genuinely has too many labels to seat without overlap.
Pick raycast for typical n=2..3 diagrams or any case where labels naturally
spread around the perimeter. Reach for forceDirected or matched 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; matched is the better choice when leader crossings are the problem, since it eliminates them by construction.
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).
Set Labels: Outside, Next to the Shape
Everything above labels regions. A different question is where to put a set’s name, and it has a different answer, because a set name has a shape it belongs to rather than a polygon it must fit inside.
By default a set name goes inside the diagram: at the pole of inaccessibility
of the set’s exclusive lobe, or — for a set with no exclusive area — folded into
the largest region that contains it. place_set_labels (Rust)/placeSetLabels (npm) offers the third option: just outside the set’s own shape, hugging the
boundary, with no leader line. The label is adjacent to the thing it names,
so nothing needs to be drawn to connect them.
The algorithm is a rotation, in three steps:
- Hug. For a candidate angle, the anchor is cast outward from the shape’s
own POI until the label box clears that shape’s outline by
margin— and no further. The clearance test uses the box’s full footprint, width and height, so a wide label on a diagonal can’t clip a curving boundary with a corner. - Rotate. Sweep the angle over the whole circle and keep the one whose
label box has the largest clearance to everything else: the other sets’
outlines, the set labels already placed, and any
obstaclesyou supply. This is what puts a set buried in a cluster on whichever side happens to be open. Ties — the common case for an isolated set, where every side is equally free — break outward, away from the diagram’s centre. - Guard. If two labels still overlap after the sweep, the same tangential
push the
raycastpolicy uses separates them. Its push axis is perpendicular to each label’s outward direction, so here “push tangentially” is “rotate further around the shape”; each pass re-hugs afterwards so a slid label doesn’t creep in along the chord.
Sets are placed one at a time in descending shape-area order, each seeing the ones before it as obstacles, so the result never depends on map iteration order.
import { euler, labelObstacles, placeLabelsForRegions, placeSetLabels } from "@jolars/eunoia";
const layout = euler({ sets, output: "regions" });
// Region quantities first — their boxes become keep-outs for the set names.
const placements = placeLabelsForRegions({ regions: layout.regions, sizes });
const setPlacements = placeSetLabels({
outlines: layout.shapeOutlines,
sizes: setSizes,
strategy: { obstacles: labelObstacles({ placements, sizes, padding: 1 }) },
});
for (const [name, p] of Object.entries(setPlacements)) {
drawLabel(p.anchor, name); // kind === "exteriorSet"; no leader to draw
} layout.shapeOutlines is the per-set silhouette, present on "regions" and "polygons" output. Region decomposition clips the shapes against one another,
so a set’s own outline can’t be recovered from the region pieces without a union
pass — the layout carries it directly.
Knobs (defaults in parentheses):
margin(`0.5 label_h`): gap between the outline and the near edge of the label box. Note this scales with the label’s height, not its longest side the way the leader-line policies’ margin does. There the margin decides how far past the whole diagram a label sits, and scaling with the long side keeps a wide label from crowding the outline. Here the label has to read asattached* to its shape, and a width-scaled gap would fling a long set name several radii away — the very ambiguity the mode exists to avoid.angularSteps(180, i.e. 2° steps): candidate angles per shape. Values under 8 are clamped up; raising it buys finer slot selection at linear cost.obstacles(none): extra axis-aligned keep-out boxes. This is the hook for everything eunoia can’t see — region quantity labels (build them withlabelObstacles), a title, a legend.precision(0.01): polylabel search precision for the ray origin inside each shape.
Two guarantees worth being precise about. Label boxes are guaranteed not to overlap each other — that is what the guard pass enforces. Clearance from the shapes is best-effort: a set nested inside a much larger one, or a label wide relative to its shape, may have no free angle at all, and the sweep then returns the least-bad one. If your diagram has deeply nested sets, the interior placement or a leader line will usually read better than this mode.
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.