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

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