Bland.Layout (Elixir Technical Drawing v0.6.0)

Copy Markdown View Source

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

Types

An axis-aligned box in pixel space.

t()

A coarse weight raster over a bounding rectangle.

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

rect()

@type rect() :: %{x: float(), y: float(), w: float(), h: float()}

An axis-aligned box in pixel space.

t()

@opaque t()

A coarse weight raster over a bounding rectangle.

Functions

best(occ, candidates)

@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(map)

@spec bottom(rect()) :: float()

Bottom edge of a rectangle.

bounds(layout)

@spec bounds(t()) :: rect()

The rectangle this raster covers.

cost(occ, r)

@spec cost(t(), rect()) :: float()

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.

ink_weight()

Weight charged for series ink.

mark_markup(occ, markup, weight \\ 1.0)

@spec mark_markup(t(), iodata(), number()) :: t()

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.

mark_point(occ, x, y, r \\ 1.0, weight \\ 1.0)

@spec mark_point(t(), number(), number(), number(), number()) :: t()

Marks a disc of radius r around a point. Non-finite coordinates are ignored.

mark_polygon(occ, points, weight \\ 1.0)

@spec mark_polygon(t(), [{number(), number()}], number()) :: t()

Marks a filled polygon — its interior, not just its outline, so a hatched area or a landmass counts as occupied all the way through.

mark_polyline(occ, points, weight \\ 1.0)

@spec mark_polyline(t(), [{number(), number()}], number()) :: t()

Marks every segment of a polyline.

mark_rect(occ, r, weight \\ 1.0)

@spec mark_rect(t(), rect(), number()) :: t()

Marks a filled rectangle.

mark_segment(occ, a, b, weight \\ 1.0)

@spec mark_segment(t(), {number(), number()}, {number(), number()}, number()) :: t()

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.

occupancy(bounds, cell \\ 8)

@spec occupancy(rect(), number()) :: t()

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.

overlap_area(a, b)

@spec overlap_area(rect(), rect()) :: float()

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

pad(map, pad)

@spec pad(rect(), number()) :: rect()

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.

place(occ, candidates)

@spec place(t(), [rect()]) :: {rect() | nil, t()}

Picks the cheapest candidate and reserves it, returning {rect, occupancy}. Returns {nil, occupancy} for an empty candidate list.

placed_weight()

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.

rect(x, y, w, h)

@spec rect(number(), number(), number(), number()) :: rect()

Builds a rectangle.

iex> Bland.Layout.rect(10, 20, 30, 40)
%{x: 10.0, y: 20.0, w: 30.0, h: 40.0}

right(map)

@spec right(rect()) :: float()

Right edge of a rectangle.

text_width(text, font_size, ratio)

@spec text_width(String.t(), number(), number()) :: float()

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

wrap_text(text, font_size, max_width, ratio, max_lines \\ 3)

@spec wrap_text(String.t(), number(), number(), number(), pos_integer()) :: [
  String.t()
]

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"]