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:

HostTypical measurement
SVG (DOM)Render hidden <text> then getBBox().
Canvasctx.measureText(label) (width) + font size (height).
R gridgrid::convertWidth(grid::grobWidth(grob), "native") and friends.
Python matplotlibRenderer’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(
    &regions,
    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:

  • raycast placement (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 by margin. 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.
  • forceDirected placement: 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.

Raycast (default). Exterior labels follow the centroid→POI ray; collisions are resolved by a sweep that nudges along the perimeter.
ForceDirected. Same warm-start positions, then iterated against label–label and label–region repulsion.

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, forceDirected only): 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 compute leaderEnd by this amount on every side, so the leader stops leaderGap units short of the rendered text edge. Negative values are clamped to 0. Set this when you hand raw measured bboxes to the placer and want breathing room between the leader tip and the glyphs; leave at 0 when 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 a combination === "" 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 (or placements_bbox in 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, since leaderEnd sits on the padded box edge. If you hand the placer raw measured bboxes instead of pre-padded ones, use strategy.leaderGap to introduce the leader-side gap independently; it only affects leaderEnd, 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.
Documentation for Eunoia v1.7.0