Skip to content

Commit

Permalink
Rewrite figure size implementations and add simplified how-to
Browse files Browse the repository at this point in the history
  • Loading branch information
jkrumbiegel committed Oct 21, 2024
1 parent 08cc331 commit ee226f5
Show file tree
Hide file tree
Showing 3 changed files with 174 additions and 49 deletions.
43 changes: 24 additions & 19 deletions docs/makedocs.jl
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ pages = [
"explanations/transparency.md",
],
"How-Tos" => [
"how-to/match-figure-size-font-sizes-and-dpi.md",
"how-to/draw-boxes-around-subfigures.md",
"how-to/save-figure-with-transparency.md",
],
Expand All @@ -197,25 +198,29 @@ pages = [
]
]

empty!(MakieDocsHelpers.FIGURES)

# filter pages here when working on docs interactively
# pages = nested_filter(pages, r"reference/blocks/(axis|axis3|overview)")

Documenter.makedocs(;
sitename="Makie",
format=DocumenterVitepress.MarkdownVitepress(;
repo = "github.com/MakieOrg/Makie.jl",
devurl = "dev",
devbranch = "master",
deploy_url = "https://docs.makie.org", # for local testing not setting this has broken links with Makie.jl in them
description = "Create impressive data visualizations with Makie, the plotting ecosystem for the Julia language. Build aesthetic plots with beautiful customizable themes, control every last detail of publication quality vector graphics, assemble complex layouts and quickly prototype interactive applications to explore your data live.",
deploy_decision,
),
pages,
expandfirst = unnest(nested_filter(pages, r"reference/(plots|blocks)/(?!overview)")),
warnonly = get(ENV, "CI", "false") != "true",
pagesonly = true,
function make_docs(; pages)
empty!(MakieDocsHelpers.FIGURES)

Documenter.makedocs(;
sitename="Makie",
format=DocumenterVitepress.MarkdownVitepress(;
repo = "github.com/MakieOrg/Makie.jl",
devurl = "dev",
devbranch = "master",
deploy_url = "https://docs.makie.org", # for local testing not setting this has broken links with Makie.jl in them
description = "Create impressive data visualizations with Makie, the plotting ecosystem for the Julia language. Build aesthetic plots with beautiful customizable themes, control every last detail of publication quality vector graphics, assemble complex layouts and quickly prototype interactive applications to explore your data live.",
deploy_decision,
),
pages,
expandfirst = unnest(nested_filter(pages, r"reference/(plots|blocks)/(?!overview)")),
warnonly = get(ENV, "CI", "false") != "true",
pagesonly = true,
)
end

make_docs(;
# filter pages here when working on docs interactively
pages # = nested_filter(pages, r"explanations/figure|match-figure"),
)

##
Expand Down
110 changes: 80 additions & 30 deletions docs/src/explanations/figure.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,52 +152,102 @@ contents(f[1, 1]) == [ax]
content(f[1, 1]) == ax
```

## Figure size and units
## Figure size and resolution

In Makie, figure size and attributes like line widths, font sizes, scatter marker extents, or layout column and row gaps are usually given as plain numbers, without an explicit unit attached.
What does it mean to have a `Figure` with `size = (600, 450)`, a line with `linewidth = 10` or a column gap of `30`?
In Makie, the **size** of a `Figure` is unitless. That is because `Figure`s can be rendered as images or vector graphics or displayed in interactive windows. The actual physical size of those outputs depends on multiple factors such as screen sizes which are often outside of Makie's control, so we don't want to promise correct output size under all circumstances for a hypothetical `Figure(size = (4cm, 5cm))`.

The first underlying idea is that, no matter what your final output format is, these numbers are _relative_.
You can expect a `linewidth = 10` to cover 1/60th of the width `600` of the `Figure` and a column gap of `30` to span 1/20th of the Figure.
This holds, no matter if you later export that `Figure` as an image made out of pixels, or as a vector graphic that doesn't have pixels at all.
The `size` of a `Figure` first and foremost tells you how much space there is for `Axis` objects and other content. For example, `fontsize` or `Axis(width = ..., height = ...)` are also unitless, but they can be understood relative to the figure size. If there is not enough space in your `Figure`, you can either increase its `size` or decrease the size of the content (for example with smaller fontsizes). However, we don't _only_ care about the `size` of the `Figure` relative to the sizes of its contents. It also has a meaning when we think about how big or small our `Figure` will look when rendered on all the different possible output devices.

The second idea is that, given some `Figure`, we want to be able to export an image at arbitrary resolution, or a vector graphic at any size from it, as long as the relative sizes of all elements stay intact.
So we need to _translate_ our abstract sizes to real sizes when we render.
In Makie, this is done with two scaling factors: `px_per_unit` for images and `pt_per_unit` for vector graphics.
Now, although Makie uses unitless numbers for figure size, it is set up by default such that these numbers can actually be thought of as CSS pixels. We have chosen this convention to simplify using Makie in web contexts which includes browser-based tools like Pluto, Jupyter notebooks or editors like VSCode. All of these use CSS to control the appearance of objects.

A line with `linewidth = 10` will be 10 pixels wide if rendered to an image file with `px_per_unit = 1`. It will be 5 pixels wide if `px_per_unit = 0.5` and 20 pixels if `px_per_unit = 2`. A `Figure` with `size = (600, 450)` will have 600 x 450 pixels when exported with `px_per_unit = 1`, 300 x 225 with `px_per_unit = 0.5` and 1200 x 900 with `px_per_unit = 2`.
At default settings, a `Figure` of size `(600, 450)` will be displayed at a size of 600 x 450 CSS pixels in web contexts (if Makie renders via the `text/html` or `image/svg+xml` MIME types). This is true irrespective of its **resolution**, i.e., how many pixels the output bitmap has. The image will be annotated with `width = "600px" height = "450px"` so that browsers will know the intended display size.

It works exactly the same for vector graphics, just with a different target unit. A `pt` or point is a typographic unit that is defined as 1/72 of an inch, which comes out to about 0.353 mm. A line with `linewidth = 10` will be 10 points wide if rendered to an svg file with `pt_per_unit = 1`, it will be 5 points wide for `pt_per_unit = 0.5` and 20 points wide if `pt_per_unit = 2`. A `Figure` with `size = (600, 450)` will be 600 x 450 points in size when exported with `pt_per_unit = 1`, 300 x 225 with `pt_per_unit = 0.5` and 1200 x 900 with `pt_per_unit = 2`.
The CSS pixel is a physical unit (1 px == 1/96 inch) but of course browsers display content on many different screens and at many different zoom levels, so you would usually not expect an element of 96px width to be exactly 1 inch wide at any given time. But even if we don't know what physical size our plots will have on our screens, we want them to fit in well next to other content and text, so we want to match the sizes conventionally used on today's systems. For example, a common fontsize is `12 pt`, which is equivalent to `16 px` (1 px == 3/4 pt).

### Defaults of `px_per_unit` and `pt_per_unit`
This also applies to pdf outputs. When preparing plots for publications, we usually want to match font sizes of their plots to the base document, for example 12pt. But today we don't usually print pdfs on paper at their intended physical dimensions. Often, they are read on mobile devices where they are zoomed in and out, so any given text will rarely be at 12pt physically.

What are the default values of `px_per_unit` and `pt_per_unit` in each Makie backend, and why are they set that way?
While vector graphics are always rendered sharply at a given zoom level, for bitmaps, the actual number of pixels decides at what zoom level or viewing distance they look sharp or blurry. This "sharpness" factor is often specified in `dpi` or dots per inch. Again, the "inch" here should not be expected to always match an actual physical inch (like in the printing days) because of the way we zoom in and out on digital screens. But if we conventionally use CSS pixels to describe sizes, we can also use `dpi` and we'll know what sharpness to expect on typical devices and typical zoom levels.

Let us start with `pt_per_unit` because this value is only relevant for one backend, which is CairoMakie.
The default value in CairoMakie is `pt_per_unit = 0.75`. So if you `save("output.svg", figure)` a `Figure` with `size = (600, 450)`, this comes out as a vector graphic that is 450 x 337.5 pt large.
To sum up, we have two factors that affect the rendered output of a Makie `Figure`. Its **size**, which determines the space available for content and the display size when interpreted in units like CSS pixels, and the **resolution** or sharpness in terms of pixel density or `dpi`. For vector graphics we only care about the size factor (unless we're embedding rasterized bitmaps in them).

Why 0.75 and not simply 1? This has to do with web standards and device-independent pixels. Websites mix vector graphics and images, so they need some way to relate the sizes of both types to each other. In principle, a pixel in an image doesn't have a real-world width. But you don't want the images on your site to shrink relative to the other content when device pixels are small, or grow when device pixels are large. So web browsers don't directly map image pixels to device pixels. Instead, they use a concept called device-independent pixels. If you place an image with 600 x 450 pixels in a website, this image is interpreted by default to be 600 x 450 device-independent pixels wide. One device-independent pixel is defined to be 0.75 pt wide, that's where the factor 0.75 comes in. So an image with 600 x 450 device-independent pixels is the same apparent size as a vector graphic with size 450 x 337.5 pt. On high-resolution screens, browsers then simply render one device-independent pixel with multiple device pixels (for example 2x2 on an Apple Retina display) so that content stays at readable sizes and doesn't look tiny.
### The `px_per_unit` factor

For Makie, we decided that we want our abstract units to match device-independent pixels when used in web contexts, because that's very convenient and easy to predict for the end user. If you have a Jupyter or Pluto notebook, it's nice if a `Figure` comes out at the same apparent size, no matter if you're currently in CairoMakie's svg mode, or in the bitmap mode of any backend. Therefore, we annotate images with the original `Figure` size in device-independent pixels, so they are of the same apparent size, no matter what the `px_per_unit` value and therefore the effective pixel size is. And we give svg files the default scaling factor of 0.75 so that svgs always match images in apparent size.
If we display a `Figure(size = (600, 450))` in a web context, by Makie's convention the image will be annotated with `width = "600px" height = "450px"`. But how many pixels does the actual bitmap have, i.e., how sharp is the image?

Now let us look at the default values for `px_per_unit`. In CairoMakie, the default is `px_per_unit = 2`. This means, a `Figure` with `size = (600, 450)` will be rendered as a 1200 x 900 pixel image. The reason it isn't `px_per_unit = 1` is that CairoMakie plots are often embedded in notebooks or websites, or looked at in image viewers or IDEs like VSCode. On websites, you don't know in advance what the pixel density of a reader's display is going to be. And in image viewers and IDEs, people like to zoom in to look at details. To cover these use cases by default, we decided `px_per_unit = 2` is a good compromise between sharp resolution and appropriate file size. Again, the _apparent_ size of output images in notebooks and websites (wherever the `MIME"text/html"` type is used) depends only on the `size`, because the output images are embedded with `<img width=$(size[1]) height=$(size[2])` no matter what value `px_per_unit` has.
This is controlled by the `px_per_unit` setting. This is `2` by default when rendering out bitmaps because many modern screens map 2x2 screen pixels to 1x1 CSS pixels (for example Apple's "retina displays"). If you want to be able to zoom in more and still have good resolution, you need to increase the `px_per_unit` value so images have even more pixels.

In GLMakie, the default behavior is different. Because GLMakie doesn't just produce images, but renders `Figure`s in interactive native windows, website or image viewer considerations don't apply. If a window covers 600 x 450 pixels of the screen it's displayed on, you want to render your `Figure` at exactly 600 x 450 pixels resolution. More, and you waste computation time, less, and your image becomes blurry. That doesn't mean however, that `px_per_unit` is always 1. Rather, `px_per_unit` automatically adjusts to the scale value of the screen that the window rendering the `Figure` is currently on. The scale value for a screen is set by the OS. On a high-dpi screen like a MacBook which has a scale factor of 2, a `Figure` of `size = (600, 450)` will be rendered with `px_per_unit = 2` to exactly cover the window's render buffer of size 1200 x 900 pixels. If you place this screen next to a regular monitor of the same physical size but with half the resolution, this monitor should have the scale factor 1 assigned to it by the OS, so that text has the same size on both of them (just sharper on the high-dpi screen). So the same `Figure` displayed on that monitor will automatically switch to `px_per_unit = 1` and therefore fill a window buffer of 600 x 450 pixels. You should never have to directly change `px_per_unit` for a window that is being displayed on a screen. However, you can still increase `px_per_unit` when saving images, so that your rendered outputs have a higher pixel count for embedding in websites or documents.
Here are two images that show how `size` and `px_per_unit` affect the visual appearance of your plots. You can remember two simple heuristics:

### Matching figure and font sizes to documents
```@setup
using CairoMakie
CairoMakie.activate!()
Academic journals usually demand that figures you submit adhere to specific physical dimensions.
How can you render Makie figures at exactly the right sizes?
sets = [
(; title = "px_per_unit", px_per_units = [0.5, 1, 2], sizes = [50, 50, 50]),
(; title = "figure_size", px_per_units = [2, 2, 2], sizes = [25, 50, 100]),
]
First, let's look at vector graphics, which are usually desired for documents because they have the best text and line rendering quality at any zoom level. The output unit of vector graphics is always `pt` in CairoMakie. You can convert to points from inches via `1 in == 72 pt` and from centimeters via `1 cm = 28.3465 pt`.
for (; title, px_per_units, sizes) in sets
f = Figure(size = (600, 500))
Let's say your desired output size is 5 x 4 inches and you should use a font size of 12 pt. You multiply 5 x 4 by 72 to get 360 x 288 pt. The size you need to set on your `Figure` depends on the `pt_per_unit` value you want to use. When making plots for publications, you should usually just save with `pt_per_unit = 1`. So in our example, we would use `Figure(size = (360, 288))` and for text set `fontsize = 12` to match the 12 pt requirement.
pixel_axes = Axis[]
for (i, (ppu, size)) in enumerate(zip(px_per_units, sizes))
bitmap = colorbuffer(let
_f = Figure(size = (size, size), fontsize = 6, figure_padding = 3, backgroundcolor = :tomato)
ax = Axis(_f[1, 1], title = "Abc", titlegap = 1)
lines!(ax, sin.(range(0, 7pi, 100)) .+ range(0, 10, 100))
hidedecorations!(ax)
_f
end; px_per_unit = ppu)
bm = rotr90(bitmap)
npix = Base.size(bm, 1)
interval = (1, npix) .- (npix+1)/2
Pixel images, on the other hand, have no inherent physical size. You can stretch any pixel image over any area in a document that you want, it will just be more or less sharp. If you want to render pixel images, you therefore have to consider what pixel density the journal demands. Usually, this value is given as dots per inch or `dpi` which is often used interchangeably with pixels per inch or `ppi`. Let's say we already have the `Figure` from our previous example with `size = (360, 288)` and `fontsize = 12`, but we want to save it as a pixel image for a target dpi or ppi of 600. We can calculate `(5, 4) inch .* (600 px / inch) ./ (360, 288)`. We only have to do it for one side because our pixels are square, so `5 * 600 / 360 == 8.3333`. That means the final image saved with `px_per_unit = 8.3333` has a size of 3000 x 2400 px, which is exactly 600 dpi when placed at a size of 5 x 4 inches.
ax, im = image(f[1, i], interval, interval, bm, interpolate = false, axis = (; autolimitaspect = 1))
We could of course have set up the `Figure` with `size = (3000, 2400)` and then saved with `px_per_unit = 1` to reach the same final size. Then, however, we would have had to calculate the fontsize that would have ended up to match 12 pt at this pixel density. Usually, when preparing figures for journals, it's easiest to use `pt` as the base unit and set everything up for saving with `pt_per_unit = 1` which makes font sizes and line widths trivial to understand for a reader of the code.
px_title = rich("px_per_unit = $ppu", color = allequal(px_per_units) ? :gray60 : :black)
size_title = rich("size = ($size, $size)", color = allequal(sizes) ? :gray60 : :black)
ax.title = rich(size_title, "\n", px_title)
!!! note
If you keep the intended physical size of an image constant and increase the dpi by increasing `px_per_unit`, the size of text and other content relative to the figure will stay constant.
However, if you instead try to increase the dpi by increasing the Figure size itself, the relative size of text and other content will shrink.
The first option is usually much more convenient, as it keeps the look and layout of the overall figure exactly the same, just with higher resolution.
push!(pixel_axes, ax)
hidedecorations!(ax)
hidespines!(ax)
ax.xlabelvisible = true
ax.xlabel = "$(join(Base.size(bm), " x ")) px"
ax.xlabelfont = :italic
ax2, im2 = image(f[2, i], rotr90(bitmap), interpolate = false, axis = (; autolimitaspect = 1, backgroundcolor = "#f5f2eb"))
ax2.xautolimitmargin = (0.1, 0.1)
ax2.yautolimitmargin = (0.1, 0.1)
hidedecorations!(ax2)
hidespines!(ax2)
text!(ax2, 0, 1, space = :relative, align = (:left, :top),
text = "Lorem ipsum dolor sit amet\nconsetetur sadipscing elitr,",
offset = (10, -10), fontsize = 12, font = :italic, color = :gray50)
text!(ax2, 0, 0, space = :relative, align = (:left, :bottom),
text = "sed diam nonumy eirmod\ninvidunt ut labore et dolore",
offset = (10, 10), fontsize = 12, font = :italic, color = :gray50)
end
linkaxes!(pixel_axes...)
rowsize!(f.layout, 1, Aspect(1, 1))
Label(f[1, 0], "Relative bitmap size", font = :bold, rotation = pi/2, tellheight = false)
Label(f[2, 0], "Scaled to same apparent size", font = :bold, rotation = pi/2, tellheight = false)
save("$title.png", f)
end
```

> Increasing `size` gives you more space for your content and a larger bitmap. When scaled to the same size in an output context (a pdf document for example), a figure with larger `size` will appear to have smaller content.
```@raw html
<img src="./figure_size.png" width="600px" height="450px"/>
```

> Increasing `px_per_unit` leaves the space for your content the same but gives a larger bitmap due to higher resolution. When scaled to the same size in an output context (a pdf document for example), a figure with larger `px_per_unit` will appear to have the same content, but sharper.
```@raw html
<img src="./px_per_unit.png" width="600px" height="450px"/>
```

There is also a `pt_per_unit` factor with which you can scale the output for vector graphics up or down. But if you keep with the convention that Makie's unitless numbers are actually CSS pixels, you can leave the default `pt_per_unit` at 0.75 and get size-matched bitmaps and vector graphics automatically.
Loading

0 comments on commit ee226f5

Please sign in to comment.