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()4d. Polar plots
Bland.polar_figure/1 sets up a square canvas with a :polar
projection (each {θ, r} point gets mapped to (r·cos θ, r·sin θ))
and a circular clip. Bland.polar_grid/2 lays down the concentric
reference rings and radial lines.
Pass θ in radians.
thetas = Enum.map(0..360, fn d -> d * :math.pi() / 180 end)
cardioid = Enum.map(thetas, fn t -> 0.5 * (1 + :math.cos(t)) end)
lobes = Enum.map(thetas, fn t -> abs(:math.cos(3 * t)) * 0.9 end)
Bland.polar_figure(size: {640, 640}, rmax: 1.0,
title: "Figure 4d — polar plot")
|> Bland.polar_grid(r_ticks: [0.25, 0.5, 0.75, 1.0], theta_step: 30)
|> Bland.line(thetas, cardioid, stroke: :solid, label: "cardioid")
|> Bland.line(thetas, lobes, stroke: :dashed, label: "|cos 3θ|")
|> Bland.legend(position: :top_right)
|> Bland.to_kino()4e. Smith charts
Bland.smith_figure/1 creates a unit-disk canvas for plotting
reflection coefficients Γ = (Z − 1) / (Z + 1). Bland.smith_grid/2
draws the classical grid: constant-resistance circles at r = 0.2, 0.5, 1, 2, 5, constant-reactance arcs at x = ±0.2, ±0.5, ±1, ±2, ±5, plus the unit-circle boundary and real axis.
Bland.smith_figure(size: {640, 640}, title: "Figure 4e — Smith chart (grid)")
|> Bland.smith_grid()
|> Bland.to_kino()Plot your own S₁₁ by converting normalized impedances to Γ via
Bland.Smith.gamma_from_z/1:
# Simulated broadband impedance sweep
gammas =
for i <- 0..60 do
t = i / 60.0
r = 0.5 + 1.5 * t
x = :math.sin(t * 3.0 * :math.pi()) * 0.8
Bland.Smith.gamma_from_z({r, x})
end
{gx, gy} = Enum.unzip(gammas)
Bland.smith_figure(size: {720, 720}, title: "S₁₁ sweep")
|> Bland.smith_grid()
|> Bland.line(gx, gy, stroke: :solid, stroke_width: 1.5, label: "S₁₁")
|> Bland.scatter([List.first(gx)], [List.first(gy)],
marker: :circle_filled, marker_size: 5, label: "start")
|> Bland.scatter([List.last(gx)], [List.last(gy)],
marker: :cross, marker_size: 6, label: "end")
|> Bland.legend(position: :bottom_right)
|> Bland.to_kino()4f. Error bars
Scatter-style data with Y uncertainty whiskers. yerr takes symmetric
half-widths or asymmetric {lo, hi} tuples per point. xerr is
supported too.
:rand.seed(:exsss, {11, 22, 33})
xs = Enum.to_list(1..10)
ys = Enum.map(xs, fn x -> 0.5 * x + (:rand.uniform() - 0.5) end)
yerr = Enum.map(xs, fn _ -> 0.2 + :rand.uniform() * 0.3 end)
Bland.figure(size: :a5_landscape, title: "Figure 4f — error bars")
|> Bland.axes(xlabel: "x", ylabel: "y")
|> Bland.errorbar(xs, ys, yerr: yerr, label: "y ± σ")
|> Bland.legend(position: :top_left)
|> Bland.to_kino()4g. Box plots
Pass raw observations per category; BLAND computes quartiles, Tukey-
fence whiskers, and outliers via Bland.Stats.boxplot_stats/2.
control = Enum.map(1..100, fn _ -> :rand.normal(0, 1) end)
treated = Enum.map(1..100, fn _ -> :rand.normal(1.2, 1.1) end)
Bland.figure(size: :a5_landscape, title: "Figure 4g — box plot")
|> Bland.axes(ylabel: "response")
|> Bland.boxplot(
[{"control", control}, {"treated", treated}],
hatch: :diagonal, label: "n=100")
|> Bland.legend(position: :top_left)
|> Bland.to_kino()4h. Contour plots
Marching squares on a 2D scalar grid at requested levels. Negative levels render dashed automatically (so sign is readable in a photocopy).
grid =
for j <- -20..20, into: [] do
for i <- -20..20, into: [] do
:math.sin(i * 0.2) * :math.cos(j * 0.2)
end
end
Bland.figure(size: :square, title: "Figure 4h — contours")
|> Bland.axes(xlabel: "x", ylabel: "y")
|> Bland.contour(grid,
x_edges: Enum.map(-20..21, &(&1 * 0.1)),
y_edges: Enum.map(-20..21, &(&1 * 0.1)),
levels: [-0.8, -0.4, -0.1, 0.1, 0.4, 0.8])
|> Bland.to_kino()4i. Stem plots (DSP)
Discrete-time signal staple: vertical line from baseline to each sample, with a marker at the tip.
ns = Enum.to_list(0..20)
vals = Enum.map(ns, fn n -> :math.cos(n * 0.5) * :math.exp(-n * 0.1) end)
Bland.figure(size: :a5_landscape, title: "Figure 4i — stem plot")
|> Bland.axes(xlabel: "n", ylabel: "x[n]")
|> Bland.stem(ns, vals, label: "x[n]")
|> Bland.hline(0.0, stroke: :solid, stroke_width: 0.5)
|> Bland.legend(position: :top_right)
|> Bland.to_kino()4j. Quiver (vector field)
Arrows at grid points. Classic for flow, EM, stress visualization.
# Rotation field: (u, v) = (-y, x)
qxs = for i <- -2..2, _ <- -2..2, do: i * 1.0
qys = for _ <- -2..2, j <- -2..2, do: j * 1.0
qus = Enum.map(qys, &(-&1 * 0.3))
qvs = Enum.map(qxs, &(&1 * 0.3))
Bland.figure(size: :square, title: "Figure 4j — rotation field")
|> Bland.axes(xlabel: "x", ylabel: "y", xlim: {-3, 3}, ylim: {-3, 3})
|> Bland.quiver(qxs, qys, qus, qvs)
|> Bland.to_kino()4k. Q-Q plot
Sample quantiles vs theoretical (normal by default) with a y=x reference. If the samples are normal, points land on the diagonal.
samples = Enum.map(1..200, fn _ -> :rand.normal(0, 1) end)
Bland.figure(size: :a5_landscape, title: "Figure 4k — Q-Q normal")
|> Bland.axes(xlabel: "theoretical quantile", ylabel: "sample quantile")
|> Bland.qq_plot(samples, label: "n=200")
|> Bland.legend(position: :top_left)
|> Bland.to_kino()4l. Bode plots
Two-panel frequency response: magnitude (dB, log ω) + phase (°, log ω).
Pass either precomputed arrays or a transfer-function callback
fn ω -> {real, imag} end.
omegas = Enum.map(-20..40, fn k -> :math.pow(10, k / 10.0) end)
# First-order lowpass with ω_c = 10:
# H(jω) = 1 / (1 + jω/10)
tf = fn w ->
denom = 1 + w * w / 100
{1 / denom, -(w / 10) / denom}
end
svg = Bland.bode(omegas, tf,
title: "Figure 4l — first-order lowpass",
xlabel: "ω [rad/s]")
# Render directly as inline SVG
Kino.Image.new(svg, "image/svg+xml")4m. Subplots
Compose independently-built figures into one SVG — any panel shape, any layout. This is how you build multi-panel reports.
xs2 = Enum.map(0..100, &(&1 / 10.0))
raw =
Bland.figure(size: {540, 300}, title: "Raw signal")
|> Bland.axes(xlabel: "t [s]", ylabel: "x(t)")
|> Bland.line(xs2, Enum.map(xs2, &(:math.cos(&1) + :math.sin(&1 * 3) * 0.3)))
spec =
Bland.figure(size: {540, 300}, title: "Envelope")
|> Bland.axes(xlabel: "t [s]", ylabel: "|x(t)|")
|> Bland.line(xs2,
Enum.map(xs2, &abs(:math.cos(&1) + :math.sin(&1 * 3) * 0.3)))
svg = Bland.grid([raw, spec], columns: 1,
cell_width: 540, cell_height: 300,
title: "Figure 4m — subplots")
Kino.Image.new(svg, "image/svg+xml")4n. Date axis + shaded reference window
xscale: :date interprets values as epoch days. Series builders
auto-convert Date.t() lists, and xlim/ylim accept Date tuples.
Ticks snap to calendar boundaries (month/quarter/year) depending on
span.
vspan/4 and hspan/4 add shaded reference regions. They render
behind every data series so curves stay on top.
:rand.seed(:exsss, {1, 2, 3})
dates = Enum.map(0..90, fn d -> Date.add(~D[2024-01-01], d) end)
prices =
Enum.reduce(1..91, [100.0], fn _, [last | _] = acc ->
[last + (:rand.uniform() - 0.5) * 2.0 | acc]
end)
|> Enum.reverse()
Bland.figure(size: :a5_landscape, title: "Figure 4n — event study")
|> Bland.axes(xlabel: "date", ylabel: "price [$]", xscale: :date)
|> Bland.vspan(~D[2024-02-10], ~D[2024-02-20],
hatch: :diagonal, alpha: 0.3, label: "embargo")
|> Bland.line(dates, prices, label: "close")
|> Bland.legend(position: :top_left)
|> Bland.to_kino()A multi-year date axis automatically picks year-start ticks:
years = Enum.map(0..(365 * 5), fn d -> Date.add(~D[2020-01-01], d) end)
vals = Enum.map(0..(365 * 5), fn i ->
100 * :math.exp(i / 2000) + 5 * :math.sin(i / 30)
end)
Bland.figure(size: :a5_landscape, title: "Five-year run")
|> Bland.axes(xscale: :date, xlabel: "date", ylabel: "value")
|> Bland.hspan(120, 140, hatch: :dots_sparse, alpha: 0.3, label: "target band")
|> Bland.line(years, vals)
|> Bland.legend(position: :top_left)
|> Bland.to_kino()4o. Live updates with Bland.Kino
Bland.Kino wraps Kino.Frame so a figure can be re-rendered in
place from a streaming data source — sensors, queue depths, prices,
anything that arrives over time.
frame = Bland.Kino.frame()Push updates from a separate cell (or a task). Each push replaces the previous figure in the frame:
# Run-as-needed: pretend each iteration is a new sensor reading
Task.async(fn ->
Enum.reduce(1..60, [], fn i, history ->
point = {i * 1.0, :math.sin(i / 5) + (:rand.uniform() - 0.5) * 0.2}
history = history ++ [point]
{xs, ys} = Enum.unzip(history)
Bland.figure(size: :a5_landscape, title: "Live sensor")
|> Bland.axes(xlabel: "t [s]", ylabel: "reading")
|> Bland.line(xs, ys, label: "ch.1")
|> Bland.legend(position: :top_right)
|> then(&Bland.Kino.push(frame, &1))
Process.sleep(200)
history
end)
end)The frame cell will update at the cadence of Process.sleep/1.
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}")