Skip to content

feat(theme): cex-aware dynamic margins and spacing primitives#591

Merged
grantmcdermott merged 14 commits into
mainfrom
cex_lab
May 19, 2026
Merged

feat(theme): cex-aware dynamic margins and spacing primitives#591
grantmcdermott merged 14 commits into
mainfrom
cex_lab

Conversation

@grantmcdermott
Copy link
Copy Markdown
Owner

Closes #590

This was much trickier to fix than I initially expected. I went down multiple manual debugging rabbit holes and dead ends, as well as numerous Claude sessions. (See "Gory Details" below.) But I think we've arrived at a robust framework and logic that yields principled + consistent outcomes.

MWE

The proof is in the pudding, so some examples first:

pkgload::load_all("~/Documents/Projects/tinyplot")
#> ℹ Loading tinyplot
tinytheme("clean", cex.axis = 3, cex.lab = 1)
tinyplot(1000:1010, xlab = "X title (JjQqYy)", ylab = "Y title (JjQqYy)",
         main = "cex.axis = 3, cex.lab = 1")
box("inner", lty = 2)

tinytheme()
tinytheme("clean", cex.axis = 1, cex.lab = 3)
tinyplot(1000:1010, xlab = "X title (JjQqYy)", ylab = "Y title (JjQqYy)",
         main = "cex.axis = 1, cex.lab = 3")
box("inner", lty = 2)

tinytheme()
tinytheme("clean", cex.axis = 3, cex.lab = 3)
tinyplot(1000:1010, xlab = "X title (JjQqYy)", ylab = "Y title (JjQqYy)",
         main = "cex.axis = 3, cex.lab = 3")
box("inner", lty = 2)

How it works

The key architectural change: mgp is now intercepted and adjusted per-side at draw time, applying differential correction logic appropriate to each axis.

R's par("mgp") is a single global value. But sides 1 and 2 (axes "x" and "y") have fundamentally different text-positioning models. Horizontal text (x-axis) is cell-centered on the specified margin line, whereas rotated text (y-axis) is baseline-anchored, extending in one direction only. This means the same mgp formula that keeps gaps constant on the x-axis (even as cex.lab changes) will yield ever-increasing gaps on the y-axis 🙃

The upshot is a single (t)par("mgp") vector cannot produce correct spacing on both sides simultaneously. So we instead compute mgp once from a shared formula---using underlying spacing primitives---and then apply side-specific corrections when drawing:

Side 1 (x-axis): No positional correction needed. R centers horizontal text on the specified margin line, so the standard mgp formula (gap + 0.5*cex) keeps gaps constant by construction. The only change is a tighter descent formula (0.2*cex_lab + 0.8) that tracks actual ink extent rather than the full cell height, preventing the bottom margin from growing with cex.

Side 2 (y-axis): Two on-the-fly corrections are applied at draw time:

  • ylab_cex_shift (0.5 * (cex_lab - 1)): R places the baseline of rotated text at the specified line (not the cell center), so the standard mgp[1] formula pushes ylab away from tick labels as cex grows. This shift cancels that drift, anchoring the visible gap constant.
  • ymgp_shift (0.5 * (cex_axis - 1)): When las ∈ {0, 1}, y-axis tick labels extend sideways into the margin, not away from the plot edge. The scaled mgp[2] over-allocates vertical clearance that isn't needed. This removes the excess by temporarily adjusting par(mgp) around the axis() call.

(Aside: Side 2's descent formula (0.1*cex_lab + 0.9) is shallower than side 1's, reflecting that the cex_shift already accounts for most of the outward extent.)

I mentioned "spacing primitives" above. These are two new user-facing tpar()/tinytheme() parameters: 1) gap.axis and 2) `gap.lab. This PR includes quite detailed documentation about each of these, so I won't go much into detail here. But in addition to facilitating the side-dependent draw-time optimization described above, they also provide a much more intuitive way to set axis label and title spacing for themes:

pkgload::load_all("~/Documents/Projects/tinyplot")
#> ℹ Loading tinyplot
tinytheme("dynamic", gap.axis = 0, gap.lab = 0.5)
tinyplot(mpg ~ hp, data = mtcars, main = "Tighter axis gaps"); box("outer", lty = 2)

tinytheme("dynamic", gap.axis = 2, gap.lab = 2)
tinyplot(mpg ~ hp, data = mtcars, main = "Looser axis gaps"); box("outer", lty = 2)

tinytheme() # reset

Gory details

I told Claude to write out a detailed log of everything we learned while iterating through this problem. I might save it as additional context that gets auto-loaded in the future. For the moment, I'll just leave it here for posterity.

Click to expand: full technical writeup on R's margin text positioning model

How R positions margin text: the gory details

This document records what we learned while implementing cex-aware dynamic
margins for tinyplot (#590). It covers how R's title() / mtext() actually
position text in margins, the asymmetry between horizontal and rotated text
placement, and the formulas we derived to keep gaps constant as cex.lab and
cex.axis scale.

Background: what we're trying to achieve

tinyplot's tinytheme() computes margins dynamically based on what's actually
being drawn (labels, titles, ticks). When a user sets cex.lab = 3 or
cex.axis = 2, the margins need to grow to accommodate the larger text. But
they should grow just enough — the visible gaps between elements (tick labels
↔ axis title, axis title ↔ figure edge) should remain constant regardless of
cex. No clipping, no growing whitespace.

This turned out to be much harder than expected because R's text positioning
model has undocumented asymmetries between horizontal and rotated text.


The character cell model

R's margin system works in "lines" — one line equals par("csi") inches at
cex = 1. All mgp, mar, and mtext(line=...) values are in these units.
At cex = k, one character cell occupies k lines.

A character "cell" at a given cex occupies 1 * cex lines vertically. But
glyphs don't fill the entire cell:

strheight("X", units = "inches", cex = k) / par("csi")  # ≈ 0.597 * k
strheight("gyp descender", units = "inches", cex = k) / par("csi")  # ≈ 0.597 * k (same!)

Key facts:

  • The glyph occupies ~60% of the cell; ~40% is internal leading
  • strheight() returns the same value for ANY string at a given cex — "X",
    "gyp", "Gg" all give 0.597*k. It's a font-level line-height metric, not a
    per-glyph bounding box
  • This ratio is constant across cex values (scales perfectly linearly)
  • There is no R API to get actual glyph ascent/descent metrics

The consequence: at large cex, there's proportionally more unused space within
each cell. Any formula that uses 0.5*cex (half-cell) to model the ink extent
will over-allocate space, and that over-allocation grows with cex.


The mgp coordinate system

par("mgp") controls where margin text is placed:

  • mgp[1]: line number for axis titles (xlab, ylab)
  • mgp[2]: line number for tick labels
  • mgp[3]: line number for the axis line itself

Lines are counted outward from the plot edge. Line 0 is the plot boundary;
larger values go further into the margin (toward the device edge).

In tinyplot's dynamic system, mgp is computed from spacing primitives:

mgp[2] = gap.axis + 0.5 * cex_axis   # tick label center
mgp[1] = mgp[2] + gap.lab + 0.5 * cex_lab  # axis title center

Where gap.axis (default 0.2) is the gap from the tick tip to the near edge
of the tick label cell, and gap.lab (default 1.0) is the gap from the far
edge of the tick label cell to the near edge of the title cell.


Side 1 (x-axis): horizontal text centering

title(xlab=...) places horizontal text such that the cell center sits at
mgp[1] margin lines from the plot edge.

    plot edge (line 0)
    |
    |  tick marks
    |  |
    |  | gap.axis (0.2 lines)
    |  |  |
    |  |  |  ┌─────────────────┐ ← mgp[2] + 0.5*cex_axis (far cell edge)
    |  |  |  │   tick labels   │   cell = cex_axis lines tall
    |  |  |  │    (centered)   │
    |  |  |  └─────────────────┘ ← mgp[2] - 0.5*cex_axis (near cell edge)
    |  |  |           |
    |  |  |           | gap.lab (1.0 lines)
    |  |  |           |
    |  |  |  ┌─────────────────┐ ← mgp[1] + 0.5*cex_lab (far cell edge)
    |  |  |  │   xlab title    │   cell = cex_lab lines tall
    |  |  |  │    (centered)   │
    |  |  |  └─────────────────┘ ← mgp[1] - 0.5*cex_lab (near cell edge)
    |  |  |           |
    |  |  |           | descent buffer
    |  |  |           |
    figure edge (line = mar[1])

The near edge of the title cell (toward ticks) is at:

mgp[1] - 0.5*cex_lab = (mgp[2] + gap.lab + 0.5*cex_lab) - 0.5*cex_lab = mgp[2] + gap.lab

The cex_lab terms cancel — the near edge is constant regardless of
cex_lab
. This is why 0.5*cex in the mgp formula keeps the gap between xlab
and tick labels fixed. The gap between adjacent text elements equals gap.lab
at all cex values.

Side 1 descent formula

For the far edge (toward device boundary), the margin must extend past
mgp[1] by enough to contain the text plus a buffer. We use:

descent = 0.2 * cex_lab + 0.8

Why not 0.5*cex_lab (half-cell)?

  • Ink only extends to ~0.3*cex below center (60% of half-cell = 0.3)
  • R does NOT clip horizontal text at the cell boundary — only at the figure
    region boundary
  • Using 0.5 would make the gap between xlab and the figure edge grow with cex
    (we tested this — the whitespace below the text visibly increases)
  • Using 0.3 makes the gap shrink slightly (we tested — at cex=5 the text
    overlaps the tick labels)
  • 0.2 was found empirically to keep the visible gap approximately constant

The 0.8 constant provides sufficient buffer for descenders at cex=1.
Empirically: clipping occurs when the total bottom margin drops below ~3.2
lines; working backward from the mgp formula gives a minimum buffer of ~0.8.

Verification: at cex_lab=1, descent = 1.0 (safe). At cex_lab=5, descent = 1.8.
The visible gap between ink bottom and figure edge stays approximately constant
because 0.2*cex closely tracks the actual ink-to-cell-bottom distance.


Side 2 (y-axis): rotated text — the critical asymmetry

This is the non-obvious part. title(ylab=...) places rotated (90°) text
completely differently from horizontal text.

Discovery: baseline anchoring

Through empirical testing (drawing reference lines at predicted positions), we
determined that R places the baseline of rotated text at the specified
margin line. The text extends in ONE direction only — outward, toward the
device edge. Nothing extends toward the plot.

    figure edge                                         plot edge
    |                                                   |
    |  buffer  |←────── 0.6*cex ──────→|                |
    |          [======== Y label ========]              |
    |                                    ↑ baseline     |
    |                                    |              |
    |                                  mgp[1]           |
    |                                    |              |
    |                                    |← gap.lab →|  |
    |                                                tick labels

This is fundamentally different from side 1:

  • Horizontal text: cell CENTER at mgp[1], text extends 0.5*cex in BOTH directions
  • Rotated text: BASELINE at mgp[1], text extends ~0.6*cex in ONE direction (outward)

Why this creates a growing gap

With the mgp formula mgp[1] = mgp[2] + gap.lab + 0.5*cex_lab, the baseline
moves outward by 0.5*cex_lab. Since the baseline IS the near edge for rotated
text, the gap between ylab and tick labels is:

visible_gap = mgp[1] - (mgp[2] + 0.5*cex_axis)
            = gap.lab + 0.5*cex_lab - 0.5*cex_axis
            = gap.lab + 0.5*(cex_lab - cex_axis)

At cex_lab=1, cex_axis=1: gap = gap.lab = 1.0 (correct)
At cex_lab=3, cex_axis=1: gap = 1.0 + 1.0 = 2.0 (GROWING!)
At cex_lab=5, cex_axis=1: gap = 1.0 + 2.0 = 3.0 (way too much)

Meanwhile, the text extends 0.6cex outward from the baseline. The total
extent from plot edge = mgp[1] + 0.6
cex, which grows faster than the margin,
causing clipping at large cex.

The fix: ylab_cex_shift

We correct the positioning by subtracting 0.5*(cex_lab - 1) from the line at
which ylab is drawn:

.ylab_cex_shift = 0.5 * (.cex_lab - 1)
ylab_line = mgp[1] + whtsbp[2] - ymgp_shift - ylab_cex_shift

This cancels the 0.5*cex_lab growth in mgp[1]. The effective baseline
position becomes:

effective_line = mgp[1] - 0.5*(cex_lab - 1)
               = mgp[2] + gap.lab + 0.5*cex_lab - 0.5*cex_lab + 0.5
               = mgp[2] + gap.lab + 0.5

This is constant regardless of cex_lab — the baseline (and thus the visible
gap to tick labels) stays anchored.

Side 2 descent formula

With the shift applied, the text far edge is at:

far_edge = effective_line + 0.6*cex_lab
         = (mgp[1] - 0.5*(cex_lab-1)) + 0.6*cex_lab
         = mgp[1] + 0.1*cex_lab + 0.5

The margin needs mgp[1] + descentfar_edge + buffer:

descent ≥ 0.1*cex_lab + 0.5 + buffer

With buffer = 0.4: descent = 0.1*cex_lab + 0.9

This gives exactly 1.0 at cex=1 (preserving existing snapshot behavior) while
maintaining constant clearance at all cex values:

  • cex=1: descent=1.0, far_edge extends 0.6 past baseline → 0.4 buffer
  • cex=3: descent=1.2, far_edge extends 1.8 past baseline → 0.4 buffer
  • cex=5: descent=1.4, far_edge extends 3.0 past baseline → 0.4 buffer

The ymgp_shift: why las=1 needs special treatment

The problem

par("mgp") is a single global value — it applies to both x and y axes.
mgp[2] controls how far tick labels sit from the axis line.

On the x-axis (side 1), tick labels are horizontal text below the axis.
They extend downward (away from the plot) by 0.5*cex_axis. The full mgp[2]
spacing is needed to prevent labels from overlapping into the plot region.

On the y-axis (side 2) with las = 1 (horizontal tick labels), the labels
extend leftward into the margin, not toward the plot. The vertical extent
is minimal. So the scaled mgp[2] = gap.axis + 0.5*cex_axis creates
unnecessary vertical gap between the plot edge and where the tick labels sit.

   With las=1 and cex.axis=3:

   Horizontal y-tick labels extend LEFT, not DOWN:

   plot edge
   |
   |←── mgp[2] = 1.7 ──→|  ← labels drawn here (way too far from edge!)
   |                      |
   |    10                |
   |     8                |
   |     6                |  ← big unnecessary gap between labels and plot edge
   |     4                |
   |     2                |

This gap is correct for side 1 (where vertical extent matters) but wrong for
side 2 with las=1 (where labels go sideways).

The fix

We compute the excess mgp[2] beyond what cex=1 needs:

.ymgp_shift = if (las %in% c(0, 1)) 0.5 * (cex_axis - 1) else 0

This is applied in two places:

  1. Margin calculation: subtract from the left margin so it doesn't
    over-allocate space for the non-existent vertical extent:

    if (.ymgp_shift > 0) .dyn[2] = .dyn[2] - .ymgp_shift
  2. Axis drawing (in facet.R): temporarily reduce par(mgp[2]) before
    drawing the y-axis, then restore:

    par(mgp = par("mgp") - c(0, .ymgp_shift, 0))
    axis(side = 2, ...)
    par(mgp = par("mgp") + c(0, .ymgp_shift, 0))
  3. ylab positioning: subtract from the ylab line offset so the title
    doesn't inherit the excess spacing:

    ylab_line_offset = whtsbp[2] - .ymgp_shift - .ylab_cex_shift

Why not axis(line = -X)?

We tried axis(side = 2, line = -shift) first. This moves the ENTIRE axis
inward — the axis line, tick marks, AND labels all shift toward the plot. But
we only want to move the labels; the axis line should stay at the plot edge
(aligned with box()). The par(mgp) override is the correct approach because
mgp only controls label and title positioning, not the axis line or tick marks
(those use tcl).

When does ymgp_shift apply?

Only when las %in% c(0, 1):

  • las = 0: labels parallel to axis. Y-axis labels are rotated 90°, extending
    leftward. Same situation as las=1 — vertical extent is just the strheight.
  • las = 1: labels always horizontal. Same reasoning.
  • las = 2: labels perpendicular to axis. Y-axis labels are horizontal (same
    as las=1 for side 2 — but this is the default for las=2 on side 1 too, so
    the shift logic could interfere; we skip it for safety).
  • las = 3: labels always vertical. Y-axis labels extend downward — the full
    mgp[2] IS needed. No shift.

whtsbp: tick label width/height bump

whtsbp (width/height tick label bump) accounts for tick labels that extend
further into the margin than mgp[2] alone predicts. This matters when:

  • y-axis labels are wide (e.g., "10,000,000") with las=1
  • x-axis labels are tall (e.g., rotated long strings) with las=2

The bump is computed from actual rendered string widths:

whtsbp_y = grconvertX(max(strwidth(yaxlabs, "figure", cex = cex_axis)), from = "nfc", to = "lines") - 1

The -1 subtracts the one line that mgp[2] already allocates for a
"standard" label width. The result is added to both the margin AND the ylab
line offset, pushing the title further out to accommodate wide labels.


Summary of all formulas

Component Formula What it does
mgp[2] gap.axis + 0.5*cex_axis Tick label cell center position
mgp[1] mgp[2] + gap.lab + 0.5*cex_lab Title cell center position (correct for side 1)
descent (side 1) 0.2*cex_lab + 0.8 Margin below xlab: ink extent + fixed buffer
descent (side 2) 0.1*cex_lab + 0.9 Margin left of ylab: accounts for cex_shift, gives 1.0 at cex=1
tick_extent max(0,-tcl) + mgp[2] + 0.4*cex_axis + 0.6 Margin for tick labels alone (no title)
ymgp_shift 0.5*(cex_axis - 1) Remove excess mgp[2] gap for las=0,1 y-axis
ylab_cex_shift 0.5*(cex_lab - 1) Anchor rotated ylab baseline near ticks

Default gap primitives

Parameter Default Meaning
gap.axis 0.2 Lines from tick tip to tick label cell near edge
gap.lab 1.0 Lines from tick label cell far edge to title cell near edge

Key gotchas for future work

  1. strheight() is useless for per-glyph measurement. It returns the same
    value for any string at a given cex. It measures the font's line height
    (~0.6 of the cell), not actual ink bounds. There is no R API to get true
    ascent/descent metrics for specific glyphs. Don't rely on it for
    distinguishing characters with descenders from those without.

  2. Horizontal vs rotated text use completely different anchoring. Horizontal
    text: cell center at specified line, extends both directions. Rotated text:
    baseline at specified line, extends one direction only. Any formula derived
    for one axis will NOT work for the other without a correction term.

  3. R clips differently for horizontal vs rotated text. Horizontal text can
    bleed past the cell boundary without being clipped (only the figure region
    boundary clips). Rotated text appears to clip at or very near the cell
    boundary. This is why the side-1 descent can be tighter (0.2cex) than
    what the cell model predicts (0.5
    cex).

  4. mgp is global — it cannot differ between sides. Any side-specific
    correction must happen at draw time via: (a) line offsets passed to
    title(), (b) temporary par(mgp=...) overrides around axis() calls, or
    (c) post-hoc adjustment variables like ymgp_shift and ylab_cex_shift.

  5. The 0.5 in mgp is correct even though ink is only 0.3. The cancellation
    that keeps the near-edge gap constant depends on having 0.5*cex in BOTH
    the mgp formula AND the cell-edge model. Using 0.3 (the ink extent) would
    make the gap shrink as cex grows, because R still positions at the cell
    center — the unused cell padding on the near side gets eaten into the gap.
    We confirmed this empirically: 0.3 caused the xlab to drift into the tick
    labels at cex=5.

  6. The gap between ink edges is NOT the same as gap.axis/gap.lab. These
    primitives represent cell-edge to cell-edge distance. The visible gap between
    ink is larger by the internal leading of both adjacent cells (~0.2cex each
    side = ~0.4
    cex total). This is acceptable because the important property is
    constancy, not absolute value. Users control the visual spacing by adjusting
    the gap primitives.

  7. recordGraphics() is required for resize correctness. All of these
    calculations depend on device dimensions (strwidth, grconvertX). If they run
    once at initial draw time, resizing the window will misalign everything.
    Coordinate-dependent calculations (especially legends) must be wrapped in
    recordGraphics() so they replay on resize.


Empirical methodology

The formulas above were derived through iterative visual testing:

  1. Generate plots at cex.lab = 1, 3, 5 with box("inner", lty=2) (plot
    region) and box("figure", col="red", lty=3) (figure region)
  2. Draw reference lines at predicted cell edges using grconvertX(..., from="lines", to="user") to verify where R actually renders text
  3. Sweep coefficient values (0.2, 0.3, 0.4, 0.5) and observe which keeps
    gaps constant on both the near and far edges simultaneously
  4. The "green/red/blue line" test was decisive for discovering the rotated
    text baseline behaviour — it showed that the cell center prediction was
    wrong, and the baseline prediction matched perfectly

Diagnostic technique: colored reference lines

The technique that cracked the positioning problem was drawing colored vertical
lines in the left margin at positions predicted by different anchoring models,
then seeing which line aligned with which text edge. This general approach —
"draw what you predict, then see if reality matches" — is invaluable for any
future margin/positioning work in R.

Script 1: Discovery (run WITHOUT ylab_cex_shift)

This is the test that revealed the asymmetry. It assumes the cell-center model
(which works for side 1 horizontal text) and draws three predictions. The
mismatch between predictions and rendered text proves that rotated text uses
baseline anchoring instead.

To reproduce the original discovery, temporarily comment out the
.ylab_cex_shift line in tinyplot.R before running.

library(tinyplot)

cex_lab = 5     # try 1, 3, 5 — differences most visible at large cex
cex_axis = 1

tinytheme("clean", cex.lab = cex_lab, cex.axis = cex_axis)
tinyplot(1:10, xlab = "gyp descender test", ylab = "Y label",
         main = sprintf("cex.lab = %g (no cex_shift)", cex_lab))

mgp1 = par("mgp")[1]
par(xpd = TRUE)  # allow drawing into margins

# 1 margin line in user-x units (side 2 lines go leftward from plot edge)
lh = par("csi") / par("pin")[1] * diff(par("usr")[1:2])
plot_left = par("usr")[1]

# Three predictions under the cell-center model:
#   Blue  = near edge:  mgp[1] - 0.5*cex (toward ticks)
#   Green = center:     mgp[1] (where cell midpoint "should" be)
#   Red   = far edge:   mgp[1] + 0.5*cex (toward device)
near_x   = plot_left - (mgp1 - 0.5 * cex_lab) * lh
center_x = plot_left - mgp1 * lh
far_x    = plot_left - (mgp1 + 0.5 * cex_lab) * lh

abline(v = near_x,   col = "blue",   lty = 2, lwd = 2)
abline(v = center_x, col = "green3", lty = 3, lwd = 2)
abline(v = far_x,    col = "red",    lty = 2, lwd = 2)

legend("topright", lty = c(2, 3, 2), lwd = 2, col = c("blue", "green3", "red"),
       legend = c("predicted near edge", "predicted center (mgp[1])",
                  "predicted far edge"), cex = 0.8)

par(xpd = FALSE)
tinytheme()

What we observed (without cex_shift):

  • The bottom (near edge) of the rendered "Y label" text always sits on
    the green line — the predicted center, i.e., raw mgp[1]
  • The middle of the text sits near the red line (predicted far edge)
  • The text never reaches the blue line (predicted near edge)

Interpretation: If the cell-center model were correct, the green line
would be at the text midpoint and blue/red would bracket it equally. Instead,
the green line is at the text's near edge. This proves R places the
baseline (not cell center) at mgp[1] for rotated text. The text extends
only leftward (~0.6*cex lines) from the baseline.

Script 2: Verification (run WITH ylab_cex_shift, i.e. current code)

After applying the fix, run this to verify the correction works. The reference
lines now show the corrected effective positions.

library(tinyplot)

cex_lab = 5     # try 1, 3, 5
cex_axis = 1

tinytheme("clean", cex.lab = cex_lab, cex.axis = cex_axis)
tinyplot(1:10, xlab = "gyp descender test", ylab = "Y label",
         main = sprintf("cex.lab = %g (with cex_shift)", cex_lab))

mgp1 = par("mgp")[1]
par(xpd = TRUE)

lh = par("csi") / par("pin")[1] * diff(par("usr")[1:2])
plot_left = par("usr")[1]

# With the shift applied, the effective baseline is at:
#   mgp[1] - 0.5*(cex_lab - 1)
# This should be constant regardless of cex_lab.
cex_shift = 0.5 * (cex_lab - 1)
effective_baseline = mgp1 - cex_shift

baseline_x = plot_left - effective_baseline * lh
far_x      = plot_left - (effective_baseline + 0.6 * cex_lab) * lh

abline(v = baseline_x, col = "green3", lty = 2, lwd = 2)
abline(v = far_x,      col = "red",    lty = 2, lwd = 2)

# Also show where the margin boundary is (should clear far_x)
mar_edge = plot_left - par("mar")[2] * lh
abline(v = mar_edge, col = "orange", lty = 3, lwd = 2)

legend("topright", lty = c(2, 2, 3), lwd = 2,
       col = c("green3", "red", "orange"),
       legend = c("effective baseline (near edge of text)",
                  "predicted far edge (0.6*cex past baseline)",
                  "figure edge (margin boundary)"),
       cex = 0.8)

par(xpd = FALSE)
tinytheme()

What to check:

  • Green line should align with the right/near edge of the ylab text
    (the side closest to the tick labels), confirming the shift anchored it
  • Red line should align with or slightly undershoot the left/far edge
    of the text ink
  • Orange line (figure edge) should sit to the left of the red line with
    ~0.4 lines of clearance — never overlapping the text

General technique for future margin debugging

  1. Formulate a hypothesis about where R places text (e.g., "cell center
    at line X")
  2. Derive predicted positions for the edges (near, center, far) under
    that hypothesis
  3. Convert line positions to user coordinates using:
    lh = par("csi") / par("pin")[axis_dim] * diff(par("usr")[axis_range])
    position = plot_edge - line_number * lh  # side 2 (left margin)
    position = plot_edge + line_number * lh  # side 1 (bottom margin, y-axis)
  4. Draw with par(xpd = TRUE) so lines extend into margins
  5. Compare predictions against rendered text — if one line consistently
    aligns with an unexpected edge, your anchoring model is wrong
  6. Test at multiple cex values (1, 3, 5) — errors in the model that are
    invisible at cex=1 become obvious at cex=5

This same approach works for debugging side 1 (horizontal text), side 3/4,
mtext() positioning, or any margin placement issue.

Use ink-extent (0.2*cex) rather than cell-extent (0.5*cex) in descent
formulas so the gap between axis titles and figure edges stays constant.

Add ylab_cex_shift to correct for R placing rotated text at the baseline
(not cell center), which otherwise causes the ylab-to-tick-label gap to
grow with cex.lab.

See SCRATCH/cex_scaling_internals.md for full derivation.
@grantmcdermott grantmcdermott changed the title Cex lab feat(theme): cex-aware dynamic margins and spacing primitives May 18, 2026
@grantmcdermott grantmcdermott merged commit 11e3135 into main May 19, 2026
3 checks passed
@zeileis
Copy link
Copy Markdown
Collaborator

zeileis commented May 19, 2026

Wow, this took some serious effort, thanks for working through this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Themes: Dynamic margins don't adjust for custom cex.lab and cex.axis

2 participants