Skip to content
SciStack
Tool Julia Beginner 35 min

CairoMakie from the ground up: a two-panel figure for one journal column

Afterwards you can build a CairoMakie figure from Figure, Axis, and GridLayout, link the axes of two panels, and save it at a journal's column width.

Field
Cross-disciplinary
Prerequisites
none beyond Julia basics
Libraries
CairoMakie 0.15.15Printf 1.11.0Random 1.11.0julia 1.13.1
Download notebook Save Mark as done

jl-cairomakie.ipynb, executed with the versions above. The download needs a free account

Run it yourself. In the Julia 1.13.1 REPL, this installs exactly the versions above:

using Pkg
Pkg.add([
    PackageSpec(name="CairoMakie", version="0.15.15"),
    PackageSpec(name="IJulia"),
])

The problem: a spectrum, its model, and its residuals in one journal column

An emission spectrum, the model fitted to it, and the residuals go into a paper as one figure, and the figure has to fit one column of a two-column journal: 8.6 cm in the Physical Review journals, about 8.3 cm in the ACS and RSC journals; your journal's guide has its own number. In CairoMakie the quick version is a scatter, a lines!, and a second scatter, and it comes out 600 by 450 units. A unit is Makie's measure for every size in a figure, which CairoMakie writes into a PDF as 0.75 pt unless told otherwise. So the PDF is 450 pt wide, 15.9 cm, and the journal shrinks it by a factor of 0.542. The axis labels, 14 units and so 10.5 pt, print at 5.7 pt. A point is 1/72 inch, the same point your word processor sizes its fonts in, and many figure guides accept nothing under 8 pt. The two panels also stand equally tall, each with its own wavelength axis.

A chromatogram under its fitted peaks, or a diffraction pattern under its refinement, has the same three parts and the same problem.

Two versions of the same spectrum figure at the same printed width of 8.6 cm. Left: Makie's defaults scaled down, with 5.7 pt labels, two wavelength axes, and equally tall panels. Right: the figure built at 8.6 cm, with 8 pt labels, one shared λ axis, a residual panel half as tall, and the legend above the data.

This is where we end up. The right half is a Figure with two Axis panels in its grid, built at 8.6 by 7.6 cm with every size in points and saved as a PDF and a 600 dpi PNG; Step 6 sets it beside the quick version at the same printed width. You need Julia's arrays, functions, and broadcasting with the dot, and nothing about Makie. CairoMakie is the Makie backend that writes files; GLMakie draws the same code into a window.

Setup

Install CairoMakie once with import Pkg; Pkg.add("CairoMakie"); Random and Printf ship with Julia. The theme is the look of every figure on this site, bound to the name SITE because Step 4 builds the print theme on top of it. Step 1 sets it aside to show what Makie does on its own. Julia's Xoshiro draws other numbers than NumPy's generator, so the noise differs from the Python version of this figure while the spectrum has the same shape.

using CairoMakie, Random, Printf

const INK, ACCENT, SECOND, MUTED = "#1f2a44", "#c8553d", "#2a7f9e", "#8a8f98"
SITE = Theme(
    size = (770, 396), fontsize = 17,
    palette = (color = [INK, ACCENT, SECOND, MUTED],),
    Axis = (topspinevisible = false, rightspinevisible = false, xgridvisible = true, ygridvisible = true),
    Lines = (linewidth = 2.5,),
)
set_theme!(SITE)

gauss(lam, a, mu, sigma) = a * exp(-0.5 * ((lam - mu) / sigma)^2)

rng = Xoshiro(3)
lam = range(560, 640, length = 321)                                   # wavelength / nm
model = @. 150 + 1.5 * (lam - 560) + gauss(lam, 600, 580, 4) + gauss(lam, 500, 615, 5)
counts = model .+ 12 .* randn(rng, length(lam))                       # the measured spectrum
resid = counts .- model

mkpath("assets")
@printf("%d points from %.0f to %.0f nm, largest reading %.0f counts\n",
        length(lam), first(lam), last(lam), maximum(counts))
321 points from 560 to 640 nm, largest reading 789 counts

Step 1: Draw the figure the quick way and shrink it to a column

The quick version leans on a convenience: scatter without a figure creates one, with an Axis and the plot, and returns all three. The code runs inside with_theme(Theme()), which puts Makie's factory defaults in force for one block instead of the setup's theme. The do turns the indented lines into a function that with_theme calls with that theme active, and its last value, fig, comes out. fig[2, 1] is the cell in row 2, column 1 of the figure's grid, below the spectrum in fig[1, 1]; the row comes first, as in a matrix. axis = (...) hands the new Axis its labels as a named tuple.

before = with_theme(Theme()) do
    fig, ax, sc = scatter(lam, counts; label = "measured")
    lines!(ax, lam, model; label = "model")
    ax.ylabel = "intensity / counts"
    axislegend(ax)
    scatter(fig[2, 1], lam, resid; axis = (xlabel = "λ / nm", ylabel = "residual / counts"))
    fig
end

w, h = size(before.scene)                                  # in units
label = content(before[2, 1]).xlabelsize[]                 # the bottom panel's x label
w_pt = w * 0.75                                            # CairoMakie's default for a PDF
scale = 8.6 / (w_pt / 72 * 2.54)
@printf("%d x %d units, label %g units\n", w, h, label)
@printf("PDF %.0f pt = %.1f cm wide, scaled to 8.6 cm by %.3f: labels %.1f pt -> %.1f pt\n",
        w_pt, w_pt / 72 * 2.54, scale, label * 0.75, label * 0.75 * scale)
before
600 x 450 units, label 14 units
PDF 450 pt = 15.9 cm wide, scaled to 8.6 cm by 0.542: labels 10.5 pt -> 5.7 pt
Measured emission spectrum and model above, residuals below, intensity and residual in counts against wavelength λ in nm, peaks near 580 and 615 nm. Makie defaults: blue dots, a model line in the same blue that vanishes under them, a framed legend, two equally tall panels, each with its own wavelength tick labels.

xlabelsize is an attribute, a container whose value can change (an Observable); the empty index [] takes out the value it holds now, and setting one is a plain assignment, as ax.ylabel = ... above.

On screen the figure passes, apart from a model line drawn in the dots' own blue, which hides it. In print the labels end at 5.7 pt; wherever this tutorial says 8 pt, use your journal's number. The residuals get as much height as the spectrum, and both panels carry a full row of wavelength numbers. Step 6 sets before beside the finished figure.

Step 2: Build the Figure and place each Axis in its grid

Built piece by piece, the figure gives you a handle on every part. Figure() is the whole canvas: it carries the size, and it is what save writes out. Its layout is a GridLayout, a grid of cells that grows as you put things into it. Axis(fig[2, 1]) creates one panel in that cell, with its own limits, labels, and plots. The functions whose names end in ! draw into an Axis that already exists; scatter without the ! makes Figure, Axis, and plot together, which is what Step 1 got back.

fig = Figure()
ax1 = Axis(fig[1, 1]; ylabel = "intensity / counts")
ax2 = Axis(fig[2, 1]; xlabel = "λ / nm", ylabel = "residual / counts")
scatter!(ax1, lam, counts; color = INK, markersize = 6, label = "measured")
lines!(ax1, lam, model; color = ACCENT, label = "model")
axislegend(ax1; framevisible = false)
scatter!(ax2, lam, resid; color = INK, markersize = 6)

println(typeof(fig.layout), " with ", size(fig.layout), " (rows, columns)")
fig
GridLayout with (2, 1) (rows, columns)
The same two panels built from Figure and two Axis objects, now in the site style: dark dots for the measured counts, a red model line, a frameless legend, a light grid, no top or right spines. Still two rows of wavelength tick labels and two equally tall panels.

Every property of an Axis is an attribute. You pass it as a keyword when the Axis is created, Axis(...; xlabel = "λ / nm"), or assign it afterwards, ax2.xlabel = "λ / nm" (where Matplotlib has set_xlabel). The grid holds two rows and one column, one panel in each. The figure now has the site's look, and both faults of Step 1 are still there: two wavelength axes and two panels of equal height.

Both panels are drawn by one function that receives the two Axis objects, so the same lines serve the screen figure here and the column figure in Step 4. Dots are sized like fonts and must shrink with the figure, so markersize is a required keyword; a bare markersize inside passes it on under its own name. A dashed gray line at zero gives the residuals a baseline to read the scatter against.

function draw_spectrum!(ax1, ax2; markersize)
    scatter!(ax1, lam, counts; color = INK, markersize, label = "measured")
    lines!(ax1, lam, model; color = ACCENT, label = "model")
    hlines!(ax2, 0; color = MUTED, linewidth = 1, linestyle = :dash)
    scatter!(ax2, lam, resid; color = INK, markersize)
end

fig = Figure()
ax1 = Axis(fig[1, 1]; ylabel = "intensity / counts")
ax2 = Axis(fig[2, 1]; xlabel = "λ / nm", ylabel = "residual / counts")
draw_spectrum!(ax1, ax2; markersize = 6)
axislegend(ax1; framevisible = false)
linkxaxes!(ax1, ax2)
hidexdecorations!(ax1; grid = false)
rowsize!(fig.layout, 2, Auto(0.5))
rowgap!(fig.layout, 8)
xlims!(ax2, 556, 644)                          # set on the bottom panel only

xmin, xmax = minimum(ax1.finallimits[])[1], maximum(ax1.finallimits[])[1]
@printf("top panel x range: %.0f to %.0f nm\n", xmin, xmax)
fig
top panel x range: 556 to 644 nm
The spectrum with one shared wavelength axis: the top panel has lost its tick labels but kept its vertical grid lines, and the residual panel is half as tall as the spectrum. New is a dashed gray line at zero residual.

Four calls do the work. linkxaxes! makes the panels share their x limits: set on the bottom panel, reported by the top one. hidexdecorations! removes the top panel's x label, tick labels, and ticks; grid = false spares its grid. Every row is Auto() by default, a share of 1 of the height, so Auto(0.5) for the residuals leaves the spectrum two thirds. rowgap! narrows the space between the rows from 18 units to 8. The GridLayout itself reserves room for tick and axis labels, so nothing is cut off and there is no layout option to switch on (Matplotlib's constrained layout does this job there).

Step 4: Size the figure and its text in points

Makie measures figure, fonts, lines, and markers in one unit, and only save decides what a unit is: pt_per_unit for a PDF or SVG, 0.75 by default, and px_per_unit for a PNG, 2 by default. With pt_per_unit = 1 a unit is a point, and a figure built in points prints at the size you built.

JOURNAL holds the print settings: the column size in points, 8 pt text, and thinner lines and legend symbols, since weights chosen for a 17-unit screen font look heavy next to 8 pt text. A section named after an element type, such as Axis = (...), sets the defaults of every element of that type.

A figure reads the theme once, when Figure() runs, and every Axis, plot, and legend added later takes its defaults from that copy. So the build sits inside with_theme with Figure() as its first line. That block sees only the theme you pass, not the one set_theme! set, so the site's colors must come along: merge(JOURNAL, SITE) joins the two, and where both set a value the first wins.

COLUMN = round.(Int, (8.6, 7.6) .* 72 ./ 2.54)        # points; Makie keeps whole units
JOURNAL = Theme(
    size = COLUMN, fontsize = 8, figure_padding = 4,
    Axis = (spinewidth = 0.6, xtickwidth = 0.6, ytickwidth = 0.6, xticksize = 3, yticksize = 3,
            xgridwidth = 0.5, ygridwidth = 0.5),
    Lines = (linewidth = 1.0,),
    Legend = (patchsize = (12, 8),),
)

fig, ax1, ax2, leg = with_theme(merge(JOURNAL, SITE)) do
    fig = Figure()
    ax1 = Axis(fig[1, 1]; ylabel = "intensity / counts")
    ax2 = Axis(fig[2, 1]; xlabel = "λ / nm", ylabel = "residual / counts")
    draw_spectrum!(ax1, ax2; markersize = 3)
    leg = axislegend(ax1; framevisible = false)
    linkxaxes!(ax1, ax2)
    hidexdecorations!(ax1; grid = false)
    rowsize!(fig.layout, 2, Auto(0.5))
    rowgap!(fig.layout, 8)
    xlims!(ax2, 556, 644)
    fig, ax1, ax2, leg
end
fig
The figure rebuilt at the column size, 8.6 by 7.6 cm, with 8 pt text, thin lines, and small dots, and the legend in the top right corner of the spectrum panel, where it covers the top of the 615 nm peak.

To check, collect the font size of every label; getproperty(ax, s) is ax.xlabelsize with the name held in a variable:

font_sizes(axes, leg) = Set(Float64[leg.labelsize[];
    [getproperty(ax, s)[] for ax in axes for s in (:xlabelsize, :ylabelsize, :xticklabelsize, :yticklabelsize)]])

@printf("%d x %d pt = %.2f x %.2f cm, ", COLUMN..., (COLUMN ./ 72 .* 2.54)...)
println("font sizes ", font_sizes((ax1, ax2), leg))
244 x 215 pt = 8.61 x 7.58 cm, font sizes Set([8.0])

The size is within 0.2 mm of 8.6 by 7.6 cm, and every label is 8. But axislegend puts the legend in a fixed corner, top right by default, and never looks for empty space. The hidden cell counts the data points under it there and, moved for a moment, at the top left:

Show code
function points_under(leg, ax)
    box = leg.layoutobservables.computedbbox[]               # figure units, margin included
    l, r, b, t = leg.margin[]
    lo, hi = minimum(box) .+ (l, b), maximum(box) .- (r, t)
    corner = minimum(ax.scene.viewport[])                    # the panel's origin in the figure
    xy = [Makie.project(ax.scene, Point2f(x, y)) .+ corner for (x, y) in zip(lam, counts)]
    return count(p -> all(lo .<= p .<= hi), xy)
end

panel_h = ax1.scene.viewport[].widths[2]
legend_h = leg.layoutobservables.computedbbox[].widths[2] - sum(leg.margin[][3:4])
@printf("legend %.0f of the panel's %d units of height\n", legend_h, panel_h)
@printf("data points under the legend, top right: %d\n", points_under(leg, ax1))
leg.halign = :left
@printf("data points under the legend, top left:  %d\n", points_under(leg, ax1))
leg.halign = :right;
legend 34 of the panel's 115 units of height
data points under the legend, top right: 24
data points under the legend, top left:  26

The legend takes almost a third of the panel's height. At the top right it covers the summit of the 615 nm peak, at the top left that of the 580 nm one. The least bad corner depends on where the peaks are.

Step 5: Give the legend a cell of its own

A legend inside a narrow panel lands wherever its corner is, whatever the data does there; a cell of its own ends the gamble. fig[0, 1] is the cell above row 1: the grid grows a row, and the panels give up the height it needs. Legend(fig[0, 1], ax1) collects the labeled plots of ax1, tellwidth = false keeps it from setting the width of its column, and padding = 0 drops the 6 units of space a legend keeps on every side of its entries, so that it sits one row gap above the panel it labels.

fig, ax1, ax2, leg = with_theme(merge(JOURNAL, SITE)) do
    fig = Figure()
    ax1 = Axis(fig[1, 1]; ylabel = "intensity / counts")
    ax2 = Axis(fig[2, 1]; xlabel = "λ / nm", ylabel = "residual / counts")
    draw_spectrum!(ax1, ax2; markersize = 3)
    leg = Legend(fig[0, 1], ax1; orientation = :horizontal, framevisible = false, tellwidth = false, padding = 0)
    linkxaxes!(ax1, ax2)
    hidexdecorations!(ax1; grid = false)
    rowsize!(fig.layout, 2, Auto(0.5))       # row 2 is still the residuals
    rowgap!(fig.layout, 8)
    xlims!(ax2, 556, 644)
    fig, ax1, ax2, leg
end
heights = [ax.scene.viewport[].widths[2] for ax in (ax1, ax2)]
println(size(fig.layout), " (rows, columns); panel heights ", heights, " units")
fig
(3, 1) (rows, columns); panel heights [104, 51] units
The column figure with the legend taken out of the spectrum panel and set in a row of its own above both panels, measured and model side by side. No data point is covered.
Show code
@printf("data points under the legend: %d\n", points_under(leg, ax1))
data points under the legend: 0

No point is covered, in this spectrum or any other. Adding row 0 renumbers nothing, so rowsize! still finds the residuals in row 2, at half the spectrum's height. A corner legend can also be rescued by raising the panel's top with ylims! until the data clears it, and with only a handful of points I would drop the legend and write the names beside the curves with text!. This is the figure to save.

Step 6: Save a PDF and a 600 dpi PNG, and check their size

For a plot, send the PDF: it stores lines and letters, not pixels, and no zoom blurs them. If the journal insists on a raster file, line art needs 600 dpi or more, twice the 300 dpi that suits photographs. With a unit at one point, px_per_unit = 600 / 72 is 600 dpi. pdf_version = "1.4" asks Cairo, the library under CairoMakie, for an older file layout that keeps each page dictionary, where the page size sits, in plain text for the check below to read.

The PNG check reads raw bytes. Every PNG opens with an 8-byte signature and the 8-byte header of its first chunk, and bytes 17 to 24 hold the width and the height as two 32-bit integers, most significant byte first. reinterpret views those eight bytes as two UInt32, and ntoh (network to host order) puts the bytes into the order your processor expects.

save("assets/spectrum-column.pdf", fig; pt_per_unit = 1, pdf_version = "1.4")
save("assets/spectrum-column.png", fig; px_per_unit = 600 / 72)
println("font sizes after saving: ", font_sizes((ax1, ax2), leg))

w, h = ntoh.(reinterpret(UInt32, read("assets/spectrum-column.png")[17:24]))
@printf("PNG: %d x %d px = %.2f x %.2f cm at 600 dpi\n", w, h, w / 600 * 2.54, h / 600 * 2.54)
font sizes after saving: Set([8.0])
PNG: 2033 x 1792 px = 8.61 x 7.59 cm at 600 dpi

The 2033 by 1792 pixels are the 244 by 215 points times 600/72, rounded. A PDF keeps each page's size in an entry named /MediaBox: four numbers, the lower-left and the upper-right corner of the page in points. Cairo puts the lower-left corner at 0 0, so the last two are width and height:

box = match(r"/MediaBox \[([^\]]*)\]", read("assets/spectrum-column.pdf", String))
x0, y0, width_pt, height_pt = parse.(Float64, split(box[1]))
@printf("PDF: %.0f x %.0f pt = %.2f x %.2f cm\n", width_pt, height_pt, width_pt / 72 * 2.54, height_pt / 72 * 2.54)
PDF: 244 x 215 pt = 8.61 x 7.58 cm

Neither file needs resizing at the journal: the page is the 8.6 cm you asked for, and labels built at 8 pt print at 8 pt. Print the PDF with scaling switched off and measure it.

Last, Step 1's quick figure reduced to 8.6 cm, next to the saved one. The cell that draws this does nothing but place two images side by side, so its code is hidden until you open it.

Show code
width = round(Int, 8.6 / 2.54 * 150)                       # 8.6 cm at 150 pixels per inch
images = [
    (colorbuffer(before; px_per_unit = width / size(before.scene)[1]),
     @sprintf("Makie defaults, scaled to 8.6 cm\nlabels %.1f pt", label * 0.75 * scale)),
    (colorbuffer(fig; px_per_unit = width / COLUMN[1]), "built at 8.6 cm\nlabels 8 pt"),
]
comparison = Figure(figure_padding = 10, backgroundcolor = :white)
for (i, (img, caption)) in enumerate(images)
    rows, cols = size(img)
    ax = Axis(comparison[1, i]; width = cols, height = rows, valign = :bottom)   # bottoms aligned
    hidedecorations!(ax); hidespines!(ax)
    image!(ax, rotr90(img))
    tightlimits!(ax)
    Label(comparison[2, i], caption; color = INK)
end
colgap!(comparison.layout, 30)
rowgap!(comparison.layout, 6)                              # captions close under the pictures
resize_to_layout!(comparison)
comparison
The default figure shrunk to 8.6 cm next to the figure built at 8.6 cm, at the same printed width. Left: 5.7 pt labels, two wavelength axes, equally tall panels, a framed legend. Right: 8 pt labels, one shared λ axis, a short residual panel, and the legend above the data.

Pitfalls

A PDF a quarter smaller than the figure you built. Leave out pt_per_unit and the page shrinks:

path = tempname() * ".pdf"
save(path, fig; pdf_version = "1.4")                         # no pt_per_unit
x0, y0, w, h = parse.(Float64, split(match(r"/MediaBox \[([^\]]*)\]", read(path, String))[1]))
@printf("PDF: %.0f x %.0f pt = %.2f x %.2f cm, so 8-unit labels print at %.0f pt\n",
        w, h, w / 72 * 2.54, h / 72 * 2.54, 8 * w / COLUMN[1])
rm(path)
PDF: 183 x 161 pt = 6.46 x 5.68 cm, so 8-unit labels print at 6 pt

By default a Makie unit stands for a screen pixel, and CairoMakie gives it 0.75 pt, the size a web browser gives a pixel. A figure sized for the screen thus keeps its look in a document; a figure sized in points loses a quarter of its width. Pass pt_per_unit = 1 to every vector save.

A print theme that changes nothing. Create the figure at the column size first and draw into it inside the block, and the block has no effect:

early = Figure(size = COLUMN)
top, bottom, early_leg = with_theme(merge(JOURNAL, SITE)) do
    a1 = Axis(early[1, 1]; ylabel = "intensity / counts")
    a2 = Axis(early[2, 1]; xlabel = "λ / nm", ylabel = "residual / counts")
    draw_spectrum!(a1, a2; markersize = 3)
    a1, a2, Legend(early[0, 1], a1; orientation = :horizontal, framevisible = false, tellwidth = false)
end
println("font sizes ", font_sizes((top, bottom), early_leg))
font sizes Set([17.0])

Every label is 17 units, the site's screen size, on a page 244 units wide. The figure took its theme when it was created, before the block began, and the panels and the legend take theirs from the figure, by Step 4's rule. Nothing warns you, and the labels come out more than twice the size you set. Make Figure() the first line inside the block. Anything you add to that figure after the block closes is safe, because it follows the figure too.

hidexdecorations! takes the grid along. Called without keywords, it hides the top panel's vertical grid lines along with its labels and ticks, and the grid then stops at the edge of the residual panel. Its keywords grid and minorgrid default to true, which means hide those too. Write hidexdecorations!(ax1; grid = false), as in Step 3.

Variations

  • A full-width figure. Change the width in COLUMN to 17.8 cm, both columns of the Physical Review journals. The fonts stay 8 pt, because they were never tied to the width.
  • Panels side by side. Add ax3 = Axis(fig[2, 2]) next to the residuals, draw hist!(ax3, resid; direction = :x), and call linkyaxes!(ax2, ax3) and colsize!(fig.layout, 2, Auto(0.3)): the distribution of the residuals on their own axis.
  • Irregular layouts. A GridLayout inside a cell (inner = GridLayout(fig[1, 2]), then Axis(inner[1, 1])) or an Axis across two cells (Axis(fig[2, 1:2])). The grid nests, so any arrangement of panels is cells inside cells.
  • One theme for the whole paper. Put JOURNAL into journal_theme.jl, include it at the top of every plotting script, and call set_theme!(JOURNAL) once. The other convention keeps the default 0.75 and converts with constants, cm = 96 / 2.54 and pt = 4 / 3 units (96 units to the inch, at 0.75 pt each); either works if you stick to one.

Cheat sheet

COLUMN = round.(Int, (8.6, 7.6) .* 72 ./ 2.54)                  # cm -> pt; a unit is a point when saved below
fig = with_theme(Theme(size = COLUMN, fontsize = 8)) do       # Figure() takes the theme: create it inside
    fig = Figure()
    ax1, ax2 = Axis(fig[1, 1]; ylabel = "y / unit"), Axis(fig[2, 1]; xlabel = "x / unit")
    linkxaxes!(ax1, ax2); hidexdecorations!(ax1; grid = false)  # one x axis, grid kept
    rowsize!(fig.layout, 2, Auto(0.5)); rowgap!(fig.layout, 8)  # bottom row half a share
    Legend(fig[0, 1], ax1; orientation = :horizontal, framevisible = false, tellwidth = false, padding = 0); fig
end
save("fig.pdf", fig; pt_per_unit = 1)                         # the default 0.75 shrinks the page
save("fig.png", fig; px_per_unit = 600 / 72)                  # 600 dpi for line art

Further reading