Collision-aware placement for everything that floats inside the plot.
The renderer draws series first and ornaments second, so by the time a legend, inset, or label needs a home the ink is already known. This module records that ink in a coarse raster and scores candidate placements against it, so a floating box lands in empty space instead of on top of the data.
A raster rather than rectangle intersection tests: it handles dense scatter, hatched fills, and projected (polar, Mercator) geometry uniformly, and its cost is proportional to plot area rather than to the number of data points.
Reading ink back out of the markup
mark_markup/2 re-reads the SVG the renderer has already emitted,
scanning element attributes rather than parsing XML. That keeps this
module decoupled from the series structs — anything the renderer can
draw is automatically accounted for, including geometry produced by a
projection.
occ =
Bland.Layout.occupancy(Bland.Layout.rect(0, 0, 400, 300), 8)
|> Bland.Layout.mark_markup(series_svg)
Bland.Layout.place(occ, candidate_boxes)
Summary
Functions
Index of the cheapest candidate, or nil for an empty list. Ties go to
the earlier candidate, so callers express preference through ordering.
Bottom edge of a rectangle.
The rectangle this raster covers.
Total weight under a candidate rectangle. Area hanging outside the raster bounds is charged too, so a box cannot escape a crowded plot by sliding off the edge.
Weight charged for series ink.
Marks every primitive found in a fragment of already-emitted SVG.
Marks a disc of radius r around a point. Non-finite coordinates are
ignored.
Marks a filled polygon — its interior, not just its outline, so a hatched area or a landmass counts as occupied all the way through.
Marks every segment of a polyline.
Marks a filled rectangle.
Marks a line segment, walking it at cell resolution so a long diagonal is charged along its whole length rather than only at its endpoints.
Builds an empty raster covering bounds at roughly cell pixel
resolution. The cell size is coarsened automatically if the requested
resolution would exceed the internal cell budget.
Area of the overlap between two rectangles; zero when they are disjoint.
Grows a rectangle by pad on every side — used to keep a little air
around a placed box so neighbours do not end up flush against it.
Picks the cheapest candidate and reserves it, returning
{rect, occupancy}. Returns {nil, occupancy} for an empty candidate
list.
Weight charged for an ornament that has already been placed. Heavier than series ink, because stacking two labels on each other is worse than putting one over a grid-thin rule.
Builds a rectangle.
Right edge of a rectangle.
Estimated rendered width of text at font_size, using the theme's
average glyph advance.
Wraps text onto at most max_lines lines no wider than max_width.
Types
Functions
@spec best(t(), [rect()]) :: non_neg_integer() | nil
Index of the cheapest candidate, or nil for an empty list. Ties go to
the earlier candidate, so callers express preference through ordering.
Bottom edge of a rectangle.
The rectangle this raster covers.
Total weight under a candidate rectangle. Area hanging outside the raster bounds is charged too, so a box cannot escape a crowded plot by sliding off the edge.
Weight charged for series ink.
Marks every primitive found in a fragment of already-emitted SVG.
Scans element attributes rather than parsing XML — the renderer's output
is machine-generated and regular, so this stays simple and fast. Content
inside <defs> is skipped: pattern tiles and clip paths are
definitions, not drawn ink.
Marks a disc of radius r around a point. Non-finite coordinates are
ignored.
Marks a filled polygon — its interior, not just its outline, so a hatched area or a landmass counts as occupied all the way through.
Marks every segment of a polyline.
Marks a filled rectangle.
Marks a line segment, walking it at cell resolution so a long diagonal is charged along its whole length rather than only at its endpoints.
Builds an empty raster covering bounds at roughly cell pixel
resolution. The cell size is coarsened automatically if the requested
resolution would exceed the internal cell budget.
Area of the overlap between two rectangles; zero when they are disjoint.
iex> a = Bland.Layout.rect(0, 0, 10, 10)
iex> Bland.Layout.overlap_area(a, Bland.Layout.rect(5, 5, 10, 10))
25.0
Grows a rectangle by pad on every side — used to keep a little air
around a placed box so neighbours do not end up flush against it.
Picks the cheapest candidate and reserves it, returning
{rect, occupancy}. Returns {nil, occupancy} for an empty candidate
list.
Weight charged for an ornament that has already been placed. Heavier than series ink, because stacking two labels on each other is worse than putting one over a grid-thin rule.
Builds a rectangle.
iex> Bland.Layout.rect(10, 20, 30, 40)
%{x: 10.0, y: 20.0, w: 30.0, h: 40.0}
Right edge of a rectangle.
Estimated rendered width of text at font_size, using the theme's
average glyph advance.
BLAND emits SVG and lets the viewer shape the text, so there are no real font metrics to consult. Erring high is deliberate: reserving slightly too much space is far less visible than a label that collides.
iex> Bland.Layout.text_width("hello", 10, 0.52)
26.0
Wraps text onto at most max_lines lines no wider than max_width.
Lines are balanced rather than greedily packed, so a two-line title
splits near its middle instead of leaving a long line above a short one.
Text that will not fit is returned on max_lines lines regardless —
the caller gets an honest overflow rather than silently dropped words.
iex> Bland.Layout.wrap_text("short title", 10, 400, 0.52)
["short title"]