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

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}")