From 08c6a17db535155d3f269d078eeb134ccc90e3ea Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 11 Sep 2026 15:24:13 -0700 Subject: [PATCH 1/3] derive derive log state from arg, not par() --- R/facet.R | 15 +++++++++++---- R/tinyAxis.R | 49 +++++++++++++++++++++++++++++++++++++------------ R/tinyplot.R | 25 +++++++++++++++++++------ 3 files changed, 67 insertions(+), 22 deletions(-) diff --git a/R/facet.R b/R/facet.R index 2dfc8d40..ae915cca 100644 --- a/R/facet.R +++ b/R/facet.R @@ -57,6 +57,13 @@ draw_facet_window = function( } + # Both dynmar branches below measure tick labels before the plot.window() + # calls in the facet loop, so the log state has to come from the caller's + # `log` argument -- par("xlog")/par("ylog") still describe the previous plot + # at that point (#725). + .xlog = grepl("x", log, fixed = TRUE) + .ylog = grepl("y", log, fixed = TRUE) + # if breaks are provided use these (but only if x/ylabs are null) if (!is.null(xaxb) && !is.null(xlabs)) xlabs = xaxb if (!is.null(yaxb) && !is.null(ylabs)) ylabs = yaxb @@ -196,7 +203,7 @@ draw_facet_window = function( } yaxlabs = axis_tick_labels( .ylabset, - lim = ylim, axb = yaxb, axl = yaxl, log = par("ylog"), + lim = ylim, axb = yaxb, axl = yaxl, log = .ylog, free_lims = .yfree, cex = .cex_yaxs ) whtsbp = if (!is.null(yaxr)) { @@ -229,7 +236,7 @@ draw_facet_window = function( } xaxlabs = axis_tick_labels( .xlabset, - lim = xlim, axb = xaxb, axl = xaxl, log = par("xlog"), + lim = xlim, axb = xaxb, axl = xaxl, log = .xlog, free_lims = .xfree, cex = .cex_xaxs ) whtsbp = if (!is.null(xaxr)) { @@ -309,7 +316,7 @@ draw_facet_window = function( # extra whitespace bump on the y axis yaxlabs = axis_tick_labels( y_axis_labels(type, y, ylabs, xlabs, flip), - lim = ylim, axb = yaxb, axl = yaxl, log = par("ylog"), + lim = ylim, axb = yaxb, axl = yaxl, log = .ylog, cex = .cex_yaxs ) if (!is.null(yaxr)) { @@ -323,7 +330,7 @@ draw_facet_window = function( # extra whitespace bump on the x axis xaxlabs = axis_tick_labels( x_axis_labels(xlabs), - lim = xlim, axb = xaxb, axl = xaxl, log = par("xlog"), + lim = xlim, axb = xaxb, axl = xaxl, log = .xlog, cex = .cex_xaxs ) if (!is.null(xaxr)) { diff --git a/R/tinyAxis.R b/R/tinyAxis.R index 0ef463bc..155e9a80 100644 --- a/R/tinyAxis.R +++ b/R/tinyAxis.R @@ -164,12 +164,14 @@ x_axis_labels = function(xlabs) { ## clear every panel's ticks, so all of them are measured and the widest wins. axis_tick_labels = function(labelset, lim, axb = NULL, axl = NULL, log = FALSE, free_lims = NULL, cex = 1) { + ticks = function(l) { + u = axis_usr(l, log = log, axb = axb) + axisTicks(usr = u[["usr"]], log = u[["log"]]) + } if (!is.null(labelset)) { out = labelset[[1L]] } else if (!is.null(free_lims)) { - sets = lapply(free_lims, function(l) { - axisTicks(usr = extendrange(l, f = 0.04), log = log) - }) + sets = lapply(free_lims, ticks) widths = vapply( sets, function(s) max(strwidth(s, "inches", cex = cex)), @@ -177,21 +179,44 @@ axis_tick_labels = function(labelset, lim, axb = NULL, axl = NULL, log = FALSE, ) out = sets[[which.max(widths)]] } else { - # A single distinct value gives a zero-width range that extendrange() can't - # pad and axisTicks() can't tick, so widen it the way plot.window() does. - # An explicit `at` (xaxb/yaxb) supplies its own ticks, hence the guard. - usr = if (diff(lim) == 0 && is.null(axb)) { - lim + c(-0.5, 0.5) - } else { - extendrange(lim, f = 0.04) - } - out = axisTicks(usr = usr, log = log) + out = ticks(lim) } if (!is.null(axl)) out = tinylabel(out, axl) out } +## Axis limits in the coordinate space axisTicks() and par("usr") speak. +## +## Callers hold limits in *data* units, but on a log axis axisTicks() reads +## its `usr` in log10 units. Keeping that conversion here -- rather than +## leaving each call site to remember it -- is the point of this helper: the +## units contract and the padding rules stay in one place. Returns the padded +## limits together with the log flag they were built under, so the caller +## hands axisTicks() a matching pair. +## +## Margin measurement runs *before* plot.window(), so `log` must come from the +## caller's own `log=` argument. par("xlog")/par("ylog") still describe the +## previous plot on the device at that point (#725). +## +## A log axis can't represent a zero or negative limit, so those fall back to a +## linear measurement: plot.window() raises its own, clearer complaint moments +## later, and log10() here would only put "NaNs produced" in front of it. +axis_usr = function(lim, log = FALSE, axb = NULL) { + log = isTRUE(log) && all(is.finite(lim)) && all(lim > 0) + if (log) lim = log10(lim) + # A single distinct value gives a zero-width range that extendrange() can't + # pad and axisTicks() can't tick, so widen it the way plot.window() does. + # An explicit `at` (xaxb/yaxb) supplies its own ticks, hence the guard. + usr = if (diff(lim) == 0 && is.null(axb)) { + lim + c(-0.5, 0.5) + } else { + extendrange(lim, f = 0.04) + } + list(usr = usr, log = log) +} + + ## Margin lines a side must reserve to clear its tick labels ("whtsbp"). ## ## Under las 1:2 the y labels are horizontal and their *width* eats into mar[2]; diff --git a/R/tinyplot.R b/R/tinyplot.R index 80d56b7c..9cd80d33 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -1317,6 +1317,11 @@ tinyplot.default = function( .whtsbp_y_raw = 0 .whtsbp_x_raw = 0 .las = get_tpar("las", tpar_list = .tpars, default = par("las")) + # Same reasoning for the log state: par("xlog")/par("ylog") still describe + # the previous plot on this device until plot.window() runs, which is after + # this block. Read the user's `log` argument instead (#725). + .xlog = grepl("x", log, fixed = TRUE) + .ylog = grepl("y", log, fixed = TRUE) # A flipped boxplot draws the x variable up the side and the y variable # along the bottom, so a rotation has to be measured against the side its # labels actually land on. (The las branches below keep indexing 1/2 @@ -1328,7 +1333,7 @@ tinyplot.default = function( if (!is.null(yaxr) || .las %in% 1:2) { yaxlabs = axis_tick_labels( y_axis_labels(type, y, ylabs, xlabs, flip), - lim = ylim, axb = yaxb, axl = yaxl, log = par("ylog"), + lim = ylim, axb = yaxb, axl = yaxl, log = .ylog, cex = .cex_yaxs ) if (!is.null(yaxr)) { @@ -1343,7 +1348,7 @@ tinyplot.default = function( if (!is.null(xaxr) || .las %in% 2:3) { xaxlabs = axis_tick_labels( x_axis_labels(xlabs), - lim = xlim, axb = xaxb, axl = xaxl, log = par("xlog"), + lim = xlim, axb = xaxb, axl = xaxl, log = .xlog, cex = .cex_xaxs ) if (!is.null(xaxr)) { @@ -1387,17 +1392,25 @@ tinyplot.default = function( max(0, fin / par("csi") - pad) } if (!is.null(xaxr)) { + .u = axis_usr(xlim, log = .xlog, axb = xaxb) .at = if (!is.null(xlabs)) as.numeric(xlabs) else - axisTicks(usr = extendrange(xlim, f = 0.04), log = par("xlog")) - .ins = axis_tick_inset(.at, extendrange(xlim, f = 0.04), .span("x")) + axisTicks(usr = .u[["usr"]], log = .u[["log"]]) + # axisTicks() reports tick locations in data units, but the inset is a + # visual fraction of the panel, so both sides of it live in usr space. + if (.u[["log"]]) .at = log10(.at) + .ins = axis_tick_inset(.at, .u[["usr"]], .span("x")) .ovh = tick_label_overhang(xaxlabs, cex = .cex_xaxs, srt = xaxr, side = .xside, inset = .ins) .dyn = .add_lean(.dyn, .ovh, .flank(.xside)) } if (!is.null(yaxr)) { + .u = axis_usr(ylim, log = .ylog, axb = yaxb) .at = if (!is.null(ylabs)) as.numeric(ylabs) else - axisTicks(usr = extendrange(ylim, f = 0.04), log = par("ylog")) - .ins = axis_tick_inset(.at, extendrange(ylim, f = 0.04), .span("y")) + axisTicks(usr = .u[["usr"]], log = .u[["log"]]) + # axisTicks() reports tick locations in data units, but the inset is a + # visual fraction of the panel, so both sides of it live in usr space. + if (.u[["log"]]) .at = log10(.at) + .ins = axis_tick_inset(.at, .u[["usr"]], .span("y")) .ovh = tick_label_overhang(yaxlabs, cex = .cex_yaxs, srt = yaxr, side = .yside, inset = .ins) .dyn = .add_lean(.dyn, .ovh, .flank(.yside)) From 804a3cbe26a593de34ddf3be760e8943b4a15c6f Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 11 Sep 2026 15:40:06 -0700 Subject: [PATCH 2/3] test --- inst/tinytest/test-tinyAxis.R | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/inst/tinytest/test-tinyAxis.R b/inst/tinytest/test-tinyAxis.R index 8582473e..5d708b82 100644 --- a/inst/tinytest/test-tinyAxis.R +++ b/inst/tinytest/test-tinyAxis.R @@ -36,3 +36,13 @@ f = function() { xaxl = cyl_labs, xaxr = 45, theme = "clean") } expect_snapshot_plot(f, label = "axis_rotation_flip") + + +# Dynmar with repeated log calls (#725) +f = function() { + tinytheme("ipsum") + on.exit(tinytheme()) + plt(disp ~ mpg, data = mtcars, log = "y") + plt(disp ~ mpg, data = mtcars, log = "y") +} +expect_silent(f()) From 8e1112c486c612d504d39f47f6f5624b3baed18b Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 11 Sep 2026 15:54:46 -0700 Subject: [PATCH 3/3] news --- NEWS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/NEWS.md b/NEWS.md index 9e6a43eb..e3d557f9 100644 --- a/NEWS.md +++ b/NEWS.md @@ -209,6 +209,11 @@ related to plot layering. See "Bug fixes" below. ### Bug fixes +- Fixed a bug where consecutive plots with (i) logged axes under (ii) a dynamic + theme would error, due to a stale `par("xlog")`/`par("ylog")` state. We now + avoid this by grabbing the log state directly from the top-level `log` + argument instead. Thanks to @eddelbuettel for the report. + (#725 @grantmcdermott) - `type_barplot()` receives several consistency improvements and bug fixes: - Passing a named atomic vector now uses the names as the bar categories, matching base `barplot()`. (#714 @grantmcdermott)