Glyphs
Glyphs are equally-sized circular marks—one per data unit—packed inside the region they belong to, in the style of eulerGlyphs (Micallef, Dragicevic & Fekete, IEEE TVCG 2012). Where an area-proportional diagram encodes quantities as region areas, glyphs encode them as countable frequencies: a region with 20 members shows 20 dots. Combining both gives a hybrid visualization where the areas and the dot counts reinforce each other.
The placer runs in the Rust core, so every binding (npm, R, Python, Julia)
gets identical output for the same input: hand it decomposed region polygons
plus a { combination → count } map and it returns a center point per glyph
plus the single radius shared by every glyph in the diagram.
Mental Model
Call place_glyphs (Rust) / placeGlyphsForRegions (npm) with a decomposed
diagram and per-region counts. You get back:
radius: the one glyph radius used diagram-wide. Equal glyph size is what makes counts comparable across regions, so there is deliberately no per-region radius.positions: a{ combination → [point, …] }map of glyph centers, exactlycounts[combination]per region.unplaced: per-region shortfall, only populated when you fixed the radius yourself and a region could not hold its count.
Every center keeps a clearance of at least radius * (1 + gap) to the region
boundary (outer ring and holes), and centers within a region stay at
least 2 * radius * (1 + gap) apart — the same gap fraction pads both, so
rendered circles never overlap each other, never leak across a boundary, and
keep visible breathing room from region edges and their strokes. Regions that
are split into several disconnected pieces have their count apportioned
across the pieces proportionally to piece area (largest-remainder rounding,
fully deterministic).
Arrangements
Two arrangements are available; both are deterministic:
uniform(default): centers sit on a hexagonal lattice anchored at the region’s pole of inaccessibility. The lattice spacing is widened as far as the region allows, so the glyphs spread across the region instead of clumping at the center. No randomness at all—the same input always gives the same output.random: seeded dart throwing with the same minimum-spacing guarantee, giving the scattered look of the original eulerGlyphs tool. Deterministic for a fixedseed(default0); each region draws from its own seed-derived stream, so one region’s scatter does not move when another region’s count changes. Darts guarantee a minimum spacing and nothing more, which reads as lumpy—pairs sitting right at the minimum next to glyph-wide voids—so a force-directed relaxation pass runs afterwards: near neighbors push each other apart and the region boundary pushes inward, over a fixed number of sweeps with no randomness of its own. Every move is accepted only if it keeps the spacing, boundary, and keep-out guarantees intact, so the pass evens the scatter out without ever moving or dropping a glyph it shouldn’t.
Radius: Auto or Fixed
Omit radius for the auto mode: the placer bisects for the largest
radius at which every requested region holds its full count. Easy diagrams
get big, readable dots; a crowded region shrinks the radius for the whole
diagram (that is the price of the equal-size invariant).
Pass an explicit radius to take control—for instance to keep glyph size
consistent across a series of related diagrams. If a region then cannot hold
its count, the placer fills it as far as it can and reports the rest in unplaced rather than erroring; check that map if dropping marks silently is
not acceptable for your use case.
The gap knob (default 0.25) adds breathing room around glyphs as a
fraction of the radius: the minimum center-to-center distance is 2 * radius * (1 + gap), and centers keep a clearance of radius * (1 + gap) to the region boundary. Raise it for a sparser, airier
look; set it to 0 to let circles touch each other and the edges.
Label Keep-Out
Labels are painted over glyphs, so anything a label covers is invisible. Pass
the boxes you measured for label placement as obstacles and glyph centers will keep the same radius * (1 + gap) clearance from them as they do from region edges:
import { labelObstacles, placeGlyphsForRegions, placeLabelsForRegions } from "@jolars/eunoia";
const placements = placeLabelsForRegions({ regions: layout.regions, sizes });
const glyphs = placeGlyphsForRegions({
regions: layout.regions,
counts,
options: {
obstacles: labelObstacles({ placements, sizes, padding: 0.15 }),
},
}); labelObstacles is the convenience bridge (label_boxes in Rust): it turns
every placement—interior and exterior, since an exterior box is the one
most likely to land on a region that is not its own—into a padded rectangle.
Obstacles are a diagram-wide list, not per region: occlusion is a question of
what is painted over what. You can add boxes of your own for a legend, a
title, or anything else drawn on top.
The list also feeds the auto-radius bisection, since a keep-out box shrinks a
region’s usable capacity. Clearance is a strong preference rather than a hard
guarantee: the radius is allowed to shrink only to half of what it would have
been without obstacles, and a region that still cannot fit its count packs
into its box rather than dropping marks. Without that floor, one small region
whose label nearly fills it would shrink every glyph in the diagram to a dot.
Boxes with a non-positive or non-finite extent (an empty label measures
exactly 0 x 0) are ignored.
Member Labels
Glyphs answer “how many”; member labels answer “which ones”. Instead of
packing anonymous dots, place_glyph_boxes (Rust) / placeGlyphBoxesForRegions (npm) packs the per-item w × h boxes you measured for the member names, and
returns one rectangle per item plus the single diagram-wide scale they were
packed at. Render your text at fontSize * scale.
import { placeGlyphBoxesForRegions } from "@jolars/eunoia";
import { toSvg } from "@jolars/eunoia/svg";
const members = {
A: ["Ada", "Grace", "Barbara"],
B: ["Alan", "Edsger"],
"A&B": ["Katherine"],
};
const FONT = 12;
const sizes = Object.fromEntries(
Object.entries(members).map(([k, names]) => [k, names.map(measureAtFont(FONT))]),
);
const placed = placeGlyphBoxesForRegions({ regions: layout.regions, sizes });
const svg = toSvg(layout, {
glyphBoxes: { ...placed, labels: members, fontSize: FONT },
}); The core stays font-blind, exactly as label
placement does: you measure, it packs. The
returned scale is the bridge back—it is the one number you need to re-render
text at the size the geometry assumes.
How the boxes are laid out
The uniform arrangement packs rows. Every row shares one height (the
diagram-wide maximum scaled box height), boxes fill a row left to right at
their own widths, the run is centered in the space it found, and the block of
rows is centered on the region’s pole of inaccessibility. Text reads in rows,
and a max-cell grid would let one long name inflate every cell. The random arrangement throws rectangular darts instead, for the scattered look—without
the relaxation pass the dot footprint gets, since heterogeneous boxes have no
single spacing to relax toward.
gap works the same way it does for discs, but as a fraction of the row
height rather than of a radius: each box carries a halo of 0.5 * gap * rowHeight, so adjacent boxes end up gap * rowHeight apart and
every box keeps half that to region boundaries, holes, and obstacles. The
shared default of 0.25 therefore reads as “a quarter of a line-height apart,
half that from the edge” in both modes.
Scale: shrink-only
Omit scale for the auto mode: a bisection for the largest factor in [minScale, 1] at which every region holds all of its items. Note the
asymmetry with the disc packer, which grows to fill—this one only ever
shrinks, because you own the reference font size and the packer has no
business enlarging past what you measured. A roomy diagram therefore renders
sparse text at your chosen size rather than blowing it up.
minScale (default 0.35) is a readability floor, not a hint: below it the
packer would rather drop items than render text nobody can read.
Dropped items are normal here
A member name is typically five to ten times wider than tall, so a region with
ample area can still fail to seat a single row of them. Expect unplaced to
be populated far more often than it ever is for discs—that is the geometry, not
a bug.
The packer takes each region’s items in order and stops at the first that
fits nowhere, so boxes[combo] is a prefix of sizes[combo] and boxes[combo][i] belongs to your labels[combo][i]. Which items get dropped is
therefore decided by the order you supply them in: sort meaningfully
(alphabetically, by salience), check unplaced, and consider rendering a
“+n more” affordance. You can also measure wrapped, multi-line boxes and
pass those—the packer needs no special support for it, and a two-line box is a
much easier shape to seat.
Obstacles compose exactly as above: pass labelObstacles(...) and the member
boxes will steer clear of your region labels too.
To see it without writing any code, type names into the per-combination roster fields in the playground and set Glyphs → Draw → Member names.
JavaScript/TypeScript
import { euler, placeGlyphsForRegions } from "@jolars/eunoia";
import { toSvg } from "@jolars/eunoia/svg";
const layout = euler({
sets: { A: 20, B: 12, "A&B": 5 },
output: "regions",
});
const glyphs = placeGlyphsForRegions({
regions: layout.regions,
counts: { A: 20, B: 12, "A&B": 5 },
});
// The SVG serializer renders them above region fills, below labels.
const svg = toSvg(layout, { glyphs }); toSvg/svgBody accept the result directly via the glyphs option and
render one <circle class="eunoia-glyph"> per point, grouped per region in a <g data-glyphs="...">. Since glyphs are strictly interior, the viewBox needs no expansion.
By default the marks borrow their region’s color: the fill is that color
lightened by tint (default 0.45), and the edge is the same color darkened,
at half the diagram’s strokeWidth so glyph outlines stay finer than the set
outlines. Give tint a negative value for marks that sit darker than their
region—useful with pale palettes, since the default palette opens on white. fill, stroke ("none" for flat dots), strokeWidth, opacity, and className all override the defaults.
Options ride along under options:
const scattered = placeGlyphsForRegions({
regions: layout.regions,
counts: { A: 20, B: 12, "A&B": 5 },
options: { arrangement: "random", seed: 7, gap: 0.4 },
}); Rust
use std::collections::HashMap;
use eunoia::plotting::{place_glyphs, GlyphOptions};
let regions = layout.region_polygons(&spec, 200);
let counts = HashMap::from([
("A".to_string(), 20),
("B".to_string(), 12),
("A&B".to_string(), 5),
]);
let glyphs = place_glyphs(®ions, &counts, &GlyphOptions::default());
for (combo, points) in &glyphs.positions {
for p in points {
draw_circle(p.x(), p.y(), glyphs.radius);
}
} Member labels use the sibling call, with measured boxes in place of counts:
use eunoia::plotting::{place_glyph_boxes, GlyphBoxOptions};
let sizes = HashMap::from([
("A".to_string(), vec![(0.30, 0.10), (0.24, 0.10)]),
("A&B".to_string(), vec![(0.20, 0.10)]),
]);
let placed = place_glyph_boxes(®ions, &sizes, &GlyphBoxOptions::default());
for (combo, rects) in &placed.boxes {
for (i, r) in rects.iter().enumerate() {
// `rects` is a prefix of `sizes[combo]`, so `i` indexes your names.
draw_text(&names[combo][i], r.center(), font_size * placed.scale);
}
} Other Bindings
The C ABI exposes the same calls as eunoia_place_glyphs and eunoia_place_glyph_boxes (JSON in, JSON out), which is what the R, Python, and Julia packages build on.
Counts and sizes are keyed by the canonical combination string ("A", "A&B", "" for the complement region), the same keying as label placement.
Notes and Roadmap
- Counts are yours to supply. Spec quantities are
f64areas and are not assumed to be cardinalities; pass the integer counts you want visualized (usually the same numbers you fed the spec, when those are frequencies). - Both work on a Venn diagram too. Venn geometry is topological, so its
regions carry no quantities of their own—supply them yourself, as counts for
the packer and (with
showCounts) asToSvgOptions.countsfor the printed numbers. The app takes both, plus a roster, per Venn region. - A blue-noise (Poisson-disk) sampler for the
randomarrangement, a phyllotaxis arrangement, and a relaxation pass for the box footprint (the dot footprint has one) are on the roadmap.