Mix.install([
{:bland, path: Path.expand("..", __DIR__)},
{:kino, "~> 0.14"}
])1. Hello, technical drawing
The simplest plot — a single curve with axis labels and a legend. Note
the serif typography, dashed grid, framed plot area, and ALL-CAPS title
with letter spacing. These are the defaults of the :report_1972 theme.
xs = Enum.map(0..100, &(&1 / 10.0))
Bland.figure(size: :a5_landscape, title: "Figure 1 — sin(x)")
|> Bland.axes(xlabel: "x [rad]", ylabel: "sin(x)", xlim: {0, 10})
|> Bland.line(xs, Enum.map(xs, &:math.sin/1), label: "y = sin(x)")
|> Bland.legend(position: :top_right)
|> Bland.to_kino()2. Multiple series, distinguishable without color
Two curves plus an envelope. BLAND cycles through a dash-preset list so each line is visually distinct even when photocopied.
decay = fn t -> :math.exp(-t / 4) end
Bland.figure(size: :a5_landscape, title: "Figure 2 — Damped oscillation")
|> Bland.axes(xlabel: "t [s]", ylabel: "x(t)", ylim: {-1.1, 1.1})
|> Bland.line(xs, Enum.map(xs, &(decay.(&1) * :math.cos(&1))), label: "signal")
|> Bland.line(xs, Enum.map(xs, &decay.(&1)), label: "envelope", stroke: :dashed)
|> Bland.line(xs, Enum.map(xs, &(-decay.(&1))), stroke: :dashed)
|> Bland.hline(0.0, stroke: :dotted)
|> Bland.legend(position: :top_right)
|> Bland.to_kino()3. Scatter + area with hatching
Synthetic noisy observations against a hatched envelope. The area fill
uses BLAND's :diagonal pattern — solid in print, readable in a
grayscale photocopy.
:rand.seed(:exsss, {1, 2, 3})
noisy = Enum.map(xs, fn x ->
:math.sin(x) + (:rand.uniform() - 0.5) * 0.3
end)
other = Enum.map(xs, fn x ->
:math.cos(x) / 2 + (:rand.uniform() - 0.5) * 0.2
end)
Bland.figure(size: :a5_landscape, title: "Figure 3 — Noisy observations")
|> Bland.axes(xlabel: "x", ylabel: "y", ylim: {-1.5, 1.5})
|> Bland.area(xs, Enum.map(xs, &:math.sin/1),
baseline: 0.0, label: "sin(x) envelope", hatch: :diagonal)
|> Bland.scatter(xs, noisy, label: "measurement A", marker: :circle_open)
|> Bland.scatter(xs, other, label: "measurement B", marker: :cross)
|> Bland.line(xs, Enum.map(xs, &:math.sin/1), stroke: :solid)
|> Bland.line(xs, Enum.map(xs, &(:math.cos(&1) / 2)), stroke: :dashed)
|> Bland.legend(position: :top_right, title: "Series")
|> Bland.to_kino()4. Grouped bars with hatch variety
Three runs across six months. Distinct hatches are clearly distinguishable without any color cue.
cats = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
Bland.figure(size: :a5_landscape, title: "Figure 4 — Monthly throughput")
|> Bland.axes(xlabel: "month", ylabel: "units / kcycle")
|> Bland.bar(cats, [12, 18, 15, 22, 19, 25], label: "run A",
hatch: :diagonal, group: :a)
|> Bland.bar(cats, [ 9, 14, 18, 19, 22, 28], label: "run B",
hatch: :crosshatch, group: :b)
|> Bland.bar(cats, [10, 11, 13, 17, 20, 22], label: "run C",
hatch: :dots_sparse, group: :c)
|> Bland.legend(position: :top_left)
|> Bland.to_kino()4b. Histograms
Raw observations go in, binned bars come out. :bins accepts an
integer count or one of :sturges (default), :sqrt, :scott,
:freedman_diaconis. Bars are flush — no category gap.
# 5000 samples from an approximately normal distribution via CLT
:rand.seed(:exsss, {42, 17, 99})
gaussianish = fn ->
Enum.reduce(1..12, 0.0, fn _, acc -> acc + :rand.uniform() end) - 6.0
end
samples = Enum.map(1..5000, fn _ -> gaussianish.() end)
Bland.figure(size: :a5_landscape, title: "Figure 4b — Sample distribution")
|> Bland.axes(xlabel: "x", ylabel: "count")
|> Bland.histogram(samples, bins: 40, label: "n = 5000", hatch: :diagonal)
|> Bland.legend(position: :top_right)
|> Bland.to_kino()Two distributions on shared bin edges, for apples-to-apples comparison:
a = Enum.map(1..2000, fn _ -> gaussianish.() - 1.0 end)
b = Enum.map(1..2000, fn _ -> gaussianish.() + 1.0 end)
shared_edges = Enum.map(-80..80, &(&1 / 20.0)) # -4 to 4, step 0.05
Bland.figure(size: :a5_landscape, title: "Two populations")
|> Bland.axes(xlabel: "x", ylabel: "density")
|> Bland.histogram(a, bin_edges: shared_edges, density: true,
label: "population A", hatch: :solid_white)
|> Bland.histogram(b, bin_edges: shared_edges, density: true,
label: "population B", hatch: :diagonal_dense)
|> Bland.legend(position: :top_right)
|> Bland.to_kino()4c. PMF, density, and empirical CDF
Same binning pipeline, four different normalizations. :count
(default) and :pmf both render as bars; :density sums to area = 1;
:cmf renders as a staircase step line.
:rand.seed(:exsss, {42, 17, 99})
gaussianish = fn ->
Enum.reduce(1..12, 0.0, fn _, acc -> acc + :rand.uniform() end) - 6.0
end
samples = Enum.map(1..5000, fn _ -> gaussianish.() end)
# PMF — probability mass per bin, Σ = 1
pmf =
Bland.figure(size: {640, 360}, title: "PMF")
|> Bland.axes(xlabel: "x", ylabel: "Pr{X ∈ bin}")
|> Bland.histogram(samples, bins: 30, normalize: :pmf, hatch: :diagonal)
|> Bland.to_kino()
# Density — ∫ = 1
density =
Bland.figure(size: {640, 360}, title: "Density")
|> Bland.axes(xlabel: "x", ylabel: "f(x)")
|> Bland.histogram(samples, bins: 30, normalize: :density, hatch: :dots_sparse)
|> Bland.to_kino()
# Empirical CDF — staircase
cdf =
Bland.figure(size: {640, 360}, title: "Empirical CDF")
|> Bland.axes(xlabel: "x", ylabel: "F(x)", ylim: {0.0, 1.05})
|> Bland.histogram(samples, bins: 50, normalize: :cmf)
|> Bland.hline(0.5, stroke: :dotted)
|> Bland.to_kino()
Kino.Layout.grid([pmf, density, cdf], columns: 1)Overlay a density histogram and its CDF on the same figure — both fit cleanly in [0, 1]:
Bland.figure(size: :a5_landscape, title: "Figure 4c — density + CDF")
|> Bland.axes(xlabel: "x", ylabel: "f(x) · F(x)", ylim: {0.0, 1.05})
|> Bland.histogram(samples, bins: 40, normalize: :density,
hatch: :diagonal, label: "density")
|> Bland.histogram(samples, bins: 50, normalize: :cmf, label: "F(x)")
|> Bland.legend(position: :top_left)
|> Bland.to_kino()5. Pattern gallery
Every preset pattern rendered side-by-side. All bars share the same group, so each one fills its category slot — a clean swatch board.
presets = Bland.Patterns.preset_cycle()
names = Enum.map(presets, &Atom.to_string/1)
fig =
Bland.figure(size: :a4_landscape, title: "Figure 5 — Pattern presets")
|> Bland.axes(grid: :none, ylim: {0, 1.2})
# One bar per category, sharing :default group so each slot fills fully.
presets
|> Enum.with_index()
|> Enum.reduce(fig, fn {p, i}, acc ->
cat = Enum.at(names, i)
Bland.bar(acc, [cat], [1.0], hatch: p)
end)
|> Bland.to_kino()6. Log axis
Logarithmic y-axis for multi-decade data. Tick placement auto-selects powers of 10.
xs2 = Enum.map(1..40, &(&1 * 1.0))
Bland.figure(size: :a5_landscape, title: "Figure 6 — Exponential growth")
|> Bland.axes(xlabel: "n", ylabel: "cycles [log]", yscale: :log,
ylim: {1.0, 100_000.0})
|> Bland.line(xs2, Enum.map(xs2, &:math.pow(2, &1 / 3)), label: "2ⁿ/³")
|> Bland.line(xs2, Enum.map(xs2, &:math.pow(3, &1 / 4)), label: "3ⁿ/⁴",
stroke: :dashed)
|> Bland.legend(position: :bottom_right)
|> Bland.to_kino()7. Annotations
In-data text and arrows, positioned in the series' own coordinate system.
Bland.figure(size: :a5_landscape, title: "Figure 7 — Peak analysis")
|> Bland.axes(xlabel: "t [s]", ylabel: "x(t)")
|> Bland.line(xs, Enum.map(xs, &(:math.exp(-&1 / 4) * :math.cos(&1))),
label: "response")
|> Bland.annotate(text: "PEAK", at: {0.1, 0.95}, anchor: "start")
|> Bland.annotate(arrow: {{0.8, 0.85}, {0.15, 0.98}})
|> Bland.annotate(text: "first zero-crossing", at: {1.6, -0.08})
|> Bland.vline(:math.pi() / 2, stroke: :dotted)
|> Bland.legend(position: :top_right)
|> Bland.to_kino()8. Engineering title block
The full drafting ornament — project, drawn-by, checked-by, date, scale, sheet, revision. The bottom margin auto-expands to make room.
Bland.figure(size: :a4_landscape, title: "Figure 8 — Full drawing")
|> Bland.axes(xlabel: "t [s]", ylabel: "amplitude", ylim: {-1.1, 1.1})
|> Bland.line(xs, Enum.map(xs, &(:math.exp(-&1 / 4) * :math.cos(&1))),
label: "signal")
|> Bland.line(xs, Enum.map(xs, &(:math.exp(-&1 / 4))),
label: "envelope", stroke: :dashed)
|> Bland.hline(0.0, stroke: :dotted)
|> Bland.legend(position: :top_right)
|> Bland.title_block(
project: "Project BLAND",
title: "Damped oscillation response",
drawn_by: "J. Doe",
checked_by: "R. Koss",
date: "1974-03-21",
scale: "1:1",
sheet: "3 of 9",
rev: "B"
)
|> Bland.to_kino()8b. Heatmap — 2D grid with hatch ramp
A 2D Gaussian, quantized to 7 hatch levels. Cells without a value range pick up the default ramp: white → dots → diagonals → crosshatch → dense dots → solid black.
grid =
for j <- -10..9, into: [] do
for i <- -10..9, into: [] do
:math.exp(-(i * i + j * j) / 40)
end
end
Bland.figure(size: :square, title: "Figure 8b — 2D Gaussian density")
|> Bland.axes(xlabel: "x", ylabel: "y")
|> Bland.heatmap(grid,
x_edges: Enum.map(-10..10, &(&1 * 1.0)),
y_edges: Enum.map(-10..10, &(&1 * 1.0)),
label: "density")
|> Bland.colorbar()
|> Bland.to_kino()A comparison heatmap with a coarser ramp — good when you want fewer, chunkier bands.
temp =
for j <- 0..15, into: [] do
for i <- 0..15, into: [] do
15 + 10 * :math.sin(i / 3) * :math.cos(j / 4)
end
end
Bland.figure(size: :a5_landscape, title: "Coarse 4-level ramp")
|> Bland.axes(xlabel: "x", ylabel: "y")
|> Bland.heatmap(temp, ramp: Bland.Heatmap.ramp(4), label: "T [°C]")
|> Bland.colorbar()
|> Bland.to_kino()8c. Geographic maps (Mercator)
projection: :mercator on a figure turns all (x, y) series data into
(lon, lat) degrees and projects through the standard web-Mercator
transform. Bland.graticule/2 adds a lat/lon grid overlay; the
Bland.Geo module exposes the projection math directly.
cities = [
{"NYC", -74.0, 40.7},
{"London", -0.1, 51.5},
{"Tokyo", 139.7, 35.7},
{"Sydney", 151.2, -33.9},
{"Cape Town", 18.4, -33.9},
{"Mexico City", -99.1, 19.4},
{"Moscow", 37.6, 55.8},
{"Rio", -43.2, -22.9}
]
lons = Enum.map(cities, fn {_, l, _} -> l end)
lats = Enum.map(cities, fn {_, _, la} -> la end)
{wx, wy} = Bland.Geo.world_rect({-180, 180}, {-80, 80})
fig =
Bland.figure(size: {960, 600}, projection: :mercator,
title: "Figure 8c — World cities",
xlim: {-180, 180}, ylim: {-80, 80}, grid: :none)
|> Bland.graticule(lon_step: 30, lat_step: 30, lat_range: {-60, 60})
|> Bland.line(wx, wy, stroke: :solid, stroke_width: 1.2)
|> Bland.scatter(lons, lats, marker: :circle_filled, marker_size: 4,
label: "cities")
Enum.reduce(cities, fig, fn {n, lo, la}, acc ->
Bland.annotate(acc, text: n, at: {lo + 3, la + 2}, font_size: 8)
end)
|> Bland.legend(position: :bottom_left)
|> Bland.to_kino()8d. Earth basemap (Natural Earth)
BLAND ships Natural Earth 1:110m and 1:50m coastline + country
vectors, vendored at compile time. No downloads at runtime. The
resolution: option picks between the datasets:
:low— 1:110m (default) · ~180 KB of compiled data:high— 1:50m · ~2.8 MB of compiled data, much more detail:schematic— BLAND's hand-drawn outlines from 0.1
Bland.figure(size: {960, 600}, projection: :mercator,
title: "Figure 8d — World (Natural Earth 1:110m)",
xlim: {-180, 180}, ylim: {-70, 75}, grid: :none)
|> Bland.graticule(lon_step: 30, lat_step: 30, lat_range: {-60, 60},
labels: false)
|> Bland.basemap(:earth_coastlines, stroke_width: 0.7)
|> Bland.basemap(:earth_borders, stroke: :dashed, stroke_width: 0.4)
|> Bland.basemap(:earth_tropics, stroke: :dotted)
|> Bland.scatter([-74.0, 139.7, -0.1], [40.7, 35.7, 51.5],
marker: :circle_filled, marker_size: 4, label: "cities")
|> Bland.legend(position: :bottom_left)
|> Bland.to_kino()Same plot at high resolution — you'll see every island and a clean rendering of fjords, peninsulas, the Caspian, and the Great Lakes:
Bland.figure(size: {1200, 700}, projection: :mercator,
title: "World (Natural Earth 1:50m)",
xlim: {-180, 180}, ylim: {-70, 78}, grid: :none)
|> Bland.graticule(lon_step: 30, lat_step: 30, lat_range: {-60, 60},
labels: false)
|> Bland.basemap(:earth_coastlines, resolution: :high, stroke_width: 0.4)
|> Bland.basemap(:earth_borders, resolution: :high, stroke: :dashed,
stroke_width: 0.25)
|> Bland.to_kino()Zoom to one country via only::
Bland.figure(size: {900, 700}, projection: :mercator,
title: "Japan · 1:50m",
xlim: {125, 150}, ylim: {30, 46}, grid: :none)
|> Bland.basemap(:earth_coastlines, resolution: :high, stroke_width: 0.6)
|> Bland.basemap(:earth_borders, resolution: :high,
only: ["Japan"], stroke_width: 0.5)
|> Bland.to_kino()Or use the schematic mode for a deliberately rough, drafted look:
Bland.figure(size: {960, 600}, projection: :mercator,
title: "World (schematic)", grid: :none,
xlim: {-180, 180}, ylim: {-70, 75})
|> Bland.basemap(:earth_coastlines, resolution: :schematic, stroke_width: 1.0)
|> Bland.to_kino()8e. Lunar near side
Selenographic coordinates plus built-in mare outlines. Fill them with a hatch pattern to get a classic lunar chart.
Bland.figure(size: {800, 800}, projection: :equirect,
title: "Figure 8e — Lunar near side",
xlim: {-95, 95}, ylim: {-70, 70}, grid: :none)
|> Bland.graticule(lon_step: 30, lat_step: 30,
lon_range: {-90, 90}, lat_range: {-60, 60}, labels: false)
|> Bland.basemap(:moon_maria, hatch: :dots_sparse, stroke_width: 0.8)
|> Bland.to_kino()Overlay the Apollo landing sites (from Apollo 11 Tranquillity Base through Apollo 17 Taurus–Littrow) as scatter points:
apollo_sites = [
{"Apollo 11", 23.47, 0.67},
{"Apollo 12", -23.42, -3.01},
{"Apollo 14", -17.47, -3.65},
{"Apollo 15", 3.63, 26.13},
{"Apollo 16", 15.50, -8.97},
{"Apollo 17", 30.77, 20.19}
]
lons = Enum.map(apollo_sites, fn {_, l, _} -> l end)
lats = Enum.map(apollo_sites, fn {_, _, l} -> l end)
fig =
Bland.figure(size: {800, 800}, projection: :equirect,
title: "Apollo landing sites", grid: :none,
xlim: {-60, 60}, ylim: {-30, 50})
|> Bland.graticule(lon_step: 15, lat_step: 15,
lon_range: {-60, 60}, lat_range: {-30, 45}, labels: false)
|> Bland.basemap(:moon_maria, hatch: :dots_sparse)
|> Bland.scatter(lons, lats, marker: :cross, marker_size: 6,
label: "Apollo site")
|> Bland.legend(position: :bottom_right)
Enum.reduce(apollo_sites, fig, fn {n, lon, lat}, acc ->
Bland.annotate(acc, text: n, at: {lon + 2, lat + 2}, font_size: 8)
end)
|> Bland.to_kino()9. Theme comparison
The same plot rendered through each built-in theme.
render = fn theme ->
Bland.figure(size: {640, 360}, theme: theme, title: Atom.to_string(theme))
|> Bland.axes(xlabel: "x", ylabel: "f(x)")
|> Bland.line(xs, Enum.map(xs, &:math.sin/1), label: "sin")
|> Bland.line(xs, Enum.map(xs, &(:math.cos(&1) / 2)),
label: "½ cos", stroke: :dashed)
|> Bland.legend(position: :top_right)
|> Bland.to_kino()
end
Kino.Layout.grid(
Enum.map([:report_1972, :blueprint, :gazette], render),
columns: 1
)10. Custom theme
Build your house style once, reuse everywhere.
house_style =
Bland.Theme.merge(:report_1972, %{
title_transform: :none,
title_font_size: 16,
grid_dasharray: "1 5",
series_stroke_width: 1.4,
tick_direction: :out,
border_inset: 8
})
Bland.figure(theme: house_style, size: :a5_landscape, title: "House style")
|> Bland.axes(xlabel: "x", ylabel: "f(x)")
|> Bland.line(xs, Enum.map(xs, &:math.sin/1), label: "sin(x)")
|> Bland.line(xs, Enum.map(xs, &:math.cos/1), label: "cos(x)", stroke: :dashed)
|> Bland.legend(position: :top_right)
|> Bland.to_kino()11. Writing to disk
Bland.write!/2 renders straight to a file. Pair it with your favorite
SVG-to-PDF converter for LaTeX or print workflows.
fig =
Bland.figure(size: :a4_landscape, title: "For the record")
|> Bland.axes(xlabel: "x", ylabel: "sin(x)")
|> Bland.line(xs, Enum.map(xs, &:math.sin/1))
tmp = Path.join(System.tmp_dir!(), "etd_showcase.svg")
:ok = Bland.write!(fig, tmp)
IO.puts("wrote #{tmp}")