From 824604f7aa4840aa505d65af276c407ee4c45590 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 17 Aug 2026 16:56:58 -0700 Subject: [PATCH 1/9] fix(facet): label categorical axes in free facets The free-facet branch selected y-axis tick labels by listing eligible plot types by name. Any type outside that list lost its `at` values while keeping the `labels` inherited from the shared axis arguments, which axis() rejects outright: Error in axis(...) : 'labels' is supplied and not 'at' Gate on `!is.null(ylabs)` instead, matching the fixed-scale branch above (#677). Named `ylabs` means the type placed categories on the y-axis, which is the property the code actually depends on. The old `isTRUE(flip)` condition was a poor proxy for it, since flipping is only one of the ways categories reach the y-axis: a plain `type = "p"` with a factor y variable hit the same error without any flip. Refs #679 --- NEWS.md | 7 +++++++ R/facet.R | 7 ++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index 2703a9c0..788751b7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -72,6 +72,13 @@ where the formatting is also better._ (#675 @haomeng797-ship-it) - Fixed several bugs specific to plots with free facets (i.e., `facet.args = list(free = TRUE)`): + - A categorical y-axis no longer errors out with `'labels' is supplied and + not 'at'`. The free-facet code path listed the eligible types by name, so + any other type lost its tick positions while keeping the corresponding + labels. This affected both flipped plots (e.g. `type = "b"` with + `flip = TRUE`) and unflipped ones that place categories on the y-axis + anyway (e.g. `type = "p"` with a factor `y` variable). + (#679 @grantmcdermott) - Single-valued discrete axes no longer trigger invalid `par(usr)` values. (#668 @grantmcdermott) - User-provided `x/ylim` overrides now work correctly with flipped plots. diff --git a/R/facet.R b/R/facet.R index 247c277d..3f23a21d 100644 --- a/R/facet.R +++ b/R/facet.R @@ -459,7 +459,12 @@ draw_facet_window = function( if (.free_axes) { .ayf = args_y .ayf[[1L]] = yfree - if (isTRUE(flip) && type %in% c("barplot", "pointrange", "errorbar", "ribbon", "boxplot", "p", "violin") && !is.null(ylabs)) { + # Same signal as the fixed-scale branch above: named `ylabs` means the + # type put categories on the y-axis. Listing eligible types by name + # instead not only dropped the labels for unlisted types, it left the + # `labels` inherited from `args_y` without a matching `at`, which + # axis() rejects outright. (#679) + if (!is.null(ylabs)) { .ayf = modifyList(.ayf, list(at = ylabs, labels = names(ylabs))) } else if (!is.null(yat)) { .ayf = modifyList(.ayf, list(at = yat)) From 534bbd534f7c16ea7c9e282d32b7df1ced758585 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 17 Aug 2026 16:57:09 -0700 Subject: [PATCH 2/9] fix(layers): align added layers by category align_layer() mapped each row of an added layer onto the base layer's axis by indexing the layer's own x positions with the looked-up original positions. That treats a per-row lookup as a permutation. It only coincided with the correct result when the layer's rows arrived in ascending order, which is the common case of one sorted row per category. Assign the lookup directly instead. Rows that arrived in any other order were previously permuted, and repeated categories collapsed onto a single x position. Refs #679 --- NEWS.md | 6 ++++++ R/align_layer.R | 7 +++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index 788751b7..8a3616c7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -70,6 +70,12 @@ where the formatting is also better._ not just the axes: `type = "h"` draws horizontal segments to the baseline, and the step types `"s"` and `"S"` swap which coordinate moves first. (#675 @haomeng797-ship-it) +- Added layers now align on the category that each row belongs to. The + realignment logic used each row's *position* rather than its category, which + only coincided with the right answer when the added layer's rows happened to + arrive in ascending order. Rows that arrived in any other order were + permuted, and repeated categories collapsed onto a single position. + (#679 @grantmcdermott) - Fixed several bugs specific to plots with free facets (i.e., `facet.args = list(free = TRUE)`): - A categorical y-axis no longer errors out with `'labels' is supplied and diff --git a/R/align_layer.R b/R/align_layer.R index 1ed43fb3..cc52d69d 100644 --- a/R/align_layer.R +++ b/R/align_layer.R @@ -36,10 +36,13 @@ align_layer = function(settings) { if (setequal(names(xlabs_layer), names(xlabs_orig))) { # If mappings already agree and no dodge, no realignment needed if (identical(xlabs_layer, xlabs_orig) && is.null(settings$dodge)) return(invisible()) - orig_order = xlabs_orig[names(xlabs_layer)[settings$datapoints[["x"]]]] x_layer = settings$datapoints[["x"]] if (is.null(settings$dodge)) { - x_new = x_layer[orig_order] + # Per-row lookup, not a permutation: the position each row's category + # occupies in the original layer. Indexing `x_layer` by it instead + # only coincided with the right answer when the layer's rows happened + # to arrive in ascending order. (#679) + x_new = unname(xlabs_orig[names(xlabs_layer)[x_layer]]) } else { names(x_layer) = names(xlabs_layer)[round(x_layer)] x_new = x_layer + (xlabs_orig[names(round(x_layer))] - round(x_layer)) From 22fb3f7ac1bb5a48b5b5e9d0914c40e4e2c9fdfb Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 17 Aug 2026 17:14:23 -0700 Subject: [PATCH 3/9] test(facet): cover categorical y-axis in free facets Levels are set in data order so the snapshot is independent of how a type orders its categories, and stays valid either way should that ordering change. Refs #679 --- .../facet_free_categorical_yaxis.svg | 114 ++++++++++++++++++ inst/tinytest/test-facet.R | 19 +++ 2 files changed, 133 insertions(+) create mode 100644 inst/tinytest/_tinysnapshot/facet_free_categorical_yaxis.svg diff --git a/inst/tinytest/_tinysnapshot/facet_free_categorical_yaxis.svg b/inst/tinytest/_tinysnapshot/facet_free_categorical_yaxis.svg new file mode 100644 index 00000000..e088df26 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/facet_free_categorical_yaxis.svg @@ -0,0 +1,114 @@ + + + + + + + + + + + + + +runtime +name + + + + + + + + + + + + + + + +210 +220 +230 +240 +250 + + + + +Fellowship +Two Towers +Return + +extended + + + + + + + + + + + + + + + + +180 +185 +190 +195 +200 + + + + +Fellowship +Two Towers +Return + +theatrical + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-facet.R b/inst/tinytest/test-facet.R index fcc7b154..e3c5523f 100644 --- a/inst/tinytest/test-facet.R +++ b/inst/tinytest/test-facet.R @@ -644,6 +644,25 @@ f = function() { } expect_snapshot_plot(f, label = "facet_axes_outer_free") +# Free facets with categories on the y-axis used to error out with +# "'labels' is supplied and not 'at'": the eligible types were listed by name, +# so anything else lost its tick positions but kept the labels. Levels are set +# in data order here, so the snapshot does not depend on how a type orders its +# categories. (#679) +f = function() { + LOTR = data.frame( + name = rep(c("Fellowship", "Two Towers", "Return"), 2), + runtime = c(178, 179, 201, 208, 223, 251), + cut = rep(c("theatrical", "extended"), each = 3) + ) + LOTR$name = factor(LOTR$name, levels = unique(LOTR$name)) + tinyplot( + runtime ~ name, facet = ~cut, data = LOTR, type = "b", + flip = TRUE, facet.args = list(free = TRUE) + ) +} +expect_snapshot_plot(f, label = "facet_free_categorical_yaxis") + # # restore original par settings # From 5c9cc282b88f014b2850d39176e5c87d1af74f22 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 17 Aug 2026 17:14:23 -0700 Subject: [PATCH 4/9] test(layers): cover added-layer category alignment Both layers use the same plot type, and the disagreement between them comes from the levels each data frame declares. Keeping the two types identical means the test does not depend on any type's category ordering, which is still under discussion. The added layer's rows deliberately arrive out of ascending order, so that a position-based mapping cannot accidentally agree with a category-based one. Refs #679 --- .../tinyplot_add_layer_category_alignment.svg | 71 +++++++++++++++++++ inst/tinytest/test-tinyplot_add.R | 17 +++++ 2 files changed, 88 insertions(+) create mode 100644 inst/tinytest/_tinysnapshot/tinyplot_add_layer_category_alignment.svg diff --git a/inst/tinytest/_tinysnapshot/tinyplot_add_layer_category_alignment.svg b/inst/tinytest/_tinysnapshot/tinyplot_add_layer_category_alignment.svg new file mode 100644 index 00000000..2030f903 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/tinyplot_add_layer_category_alignment.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + +g +y + + + + +a +b +c + + + + + + + + + +0.5 +1.0 +1.5 +2.0 +2.5 +3.0 +3.5 +4.0 + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-tinyplot_add.R b/inst/tinytest/test-tinyplot_add.R index e2921243..e1e67e11 100644 --- a/inst/tinytest/test-tinyplot_add.R +++ b/inst/tinytest/test-tinyplot_add.R @@ -125,3 +125,20 @@ f = function() { tinyplot_add(subset = cyl == 4, col = "red", pch = 16) } expect_snapshot_plot(f, label = "tinyplot_add_subset") + +# Layer alignment follows the category each row belongs to, not the row's +# position, which is what the two layers disagreed on: the added rows were +# permuted rather than matched up by category. Both layers use the same type +# here, and the disagreement comes from the levels each data frame declares, +# so the test does not depend on how any given type orders its categories. +# The red points should sit directly above the black ones. (#679) +f = function() { + d1 = data.frame(g = factor(c("a", "b", "c")), y = c(1, 2, 3)) + d2 = data.frame( + g = factor(c("a", "b", "c"), levels = c("c", "b", "a")), + y = c(1.5, 2.5, 3.5) + ) + tinyplot(y ~ g, data = d1, type = "p", ylim = c(0.5, 4)) + tinyplot_add(y ~ g, data = d2, type = "p", col = "red", pch = 16) +} +expect_snapshot_plot(f, label = "tinyplot_add_layer_category_alignment") From 0373297f84aab5f155d764df37fab6efc5ac18b4 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 17 Aug 2026 18:48:29 -0700 Subject: [PATCH 5/9] feat(types): unify categorical ordering, add xlevels Line types (type = "l", "b", "h", ... via type_lines()) now place categorical data exactly like type_points() does: categories follow the factor levels rather than their order of appearance in the data, so an explicit factor(x, levels = ...) is honoured and layered point/ line types land on the same categories. Factor y variables are also handled now, instead of falling back to a numeric axis for every line type except "p". To reorder on the fly, type_points(), type_lines(), type_errorbar(), and type_pointrange() gain an `xlevels` argument, extending the convention already established by type_barplot(), type_spineplot(), and type_ridge() (ylevels). Accepted values are a character vector of level names, numeric level indexes (e.g. 3:1), or the new keyword "data" (first appearance in the data), which the three existing types now also accept. The argument only affects categorical variables and is forwarded automatically from the top-level tinyplot() call. type_errorbar() and type_pointrange() default to xlevels = "data", preserving their existing behaviour (typically coefficient plots, where the row order of the data is intentional) while making it overridable via xlevels = NULL. The level-reordering logic that was previously copy-pasted across barplot/spineplot/ridge is consolidated into a shared sanitize_xlevels() helper, which all seven types now use. Ridge additionally gains the unknown-level warning the other types had. Closes #679 --- NEWS.md | 27 ++++++++ R/sanitize_xlevels.R | 38 ++++++++++ R/type_barplot.R | 10 ++- R/type_errorbar.R | 14 +++- R/type_lines.R | 59 ++++++++++++---- R/type_pointrange.R | 14 ++-- R/type_points.R | 14 +++- R/type_ridge.R | 5 +- R/type_spineplot.R | 12 ++-- .../_tinysnapshot/pointrange_xlevels_null.svg | 69 +++++++++++++++++++ .../type_lines_categorical_lines.svg | 62 +++++++++++++++++ .../type_lines_categorical_points.svg | 62 +++++++++++++++++ .../type_lines_categorical_y.svg | 64 +++++++++++++++++ .../type_lines_explicit_levels.svg | 64 +++++++++++++++++ .../_tinysnapshot/type_lines_flip_labels.svg | 64 +++++++++++++++++ .../_tinysnapshot/type_lines_layer_h_p.svg | 65 +++++++++++++++++ .../_tinysnapshot/type_lines_xlevels_data.svg | 64 +++++++++++++++++ .../_tinysnapshot/type_points_xlevels_idx.svg | 62 +++++++++++++++++ inst/tinytest/test-type_lines.R | 60 ++++++++++++++++ inst/tinytest/test-type_pointrange.R | 16 +++++ man/type_barplot.Rd | 4 +- man/type_errorbar.Rd | 15 +++- man/type_lines.Rd | 26 ++++++- man/type_points.Rd | 10 ++- man/type_ridge.Rd | 3 +- man/type_spineplot.Rd | 4 +- 26 files changed, 859 insertions(+), 48 deletions(-) create mode 100644 R/sanitize_xlevels.R create mode 100644 inst/tinytest/_tinysnapshot/pointrange_xlevels_null.svg create mode 100644 inst/tinytest/_tinysnapshot/type_lines_categorical_lines.svg create mode 100644 inst/tinytest/_tinysnapshot/type_lines_categorical_points.svg create mode 100644 inst/tinytest/_tinysnapshot/type_lines_categorical_y.svg create mode 100644 inst/tinytest/_tinysnapshot/type_lines_explicit_levels.svg create mode 100644 inst/tinytest/_tinysnapshot/type_lines_flip_labels.svg create mode 100644 inst/tinytest/_tinysnapshot/type_lines_layer_h_p.svg create mode 100644 inst/tinytest/_tinysnapshot/type_lines_xlevels_data.svg create mode 100644 inst/tinytest/_tinysnapshot/type_points_xlevels_idx.svg create mode 100644 inst/tinytest/test-type_lines.R diff --git a/NEWS.md b/NEWS.md index 8a3616c7..625f8505 100644 --- a/NEWS.md +++ b/NEWS.md @@ -23,6 +23,20 @@ where the formatting is also better._ #### Other new features +- `type_points()`, `type_lines()`, `type_errorbar()`, and `type_pointrange()` + gain an `xlevels` argument for reordering a categorical (factor or character) + `x` variable on the fly, extending the convention already established by + `type_barplot()`, `type_spineplot()`, and `type_ridge()` (`ylevels`). Accepted + values are a character vector of level names, a numeric vector of level + indexes (e.g., `3:1`), or the new keyword `"data"`, which orders the levels by + their first appearance in the data; the keyword is also accepted by the three + existing types. The argument is forwarded automatically from the top-level + call, e.g. `tinyplot(runtime ~ name, data = LOTR, type = "b", xlevels = + "data")`. It only affects categorical variables and is ignored for numeric + ones. `type_errorbar()` and `type_pointrange()` default to `xlevels = "data"` + (unchanged behaviour, now overridable): these types are typically used for + coefficient plots, where the row order of the data is intentional. (#679 + @grantmcdermott) - Custom plot types have more control over the surrounding plot machinery, via a new `type_hints` mechanism. A type can declare properties about itself---that it draws its own axes, needs a secondary right-hand axis, uses proportional @@ -70,6 +84,19 @@ where the formatting is also better._ not just the axes: `type = "h"` draws horizontal segments to the baseline, and the step types `"s"` and `"S"` swap which coordinate moves first. (#675 @haomeng797-ship-it) +- The line types (`type = "l"`, `"b"`, `"h"`, ... and their `type_lines()` + equivalent) now place categorical data exactly like `type_points()` does. + Categories are ordered by their factor levels rather than by order of + appearance in the data; an explicit `factor(x, levels = ...)` was previously + ignored by the line types, and layering points on lines (or vice versa) could + place the two layers against different orderings. Note that this ordering is + a property of the data, not of the plot type: to order categories by their + appearance in the data, use the new `xlevels = "data"` argument (see above) + or set the levels accordingly, e.g. `factor(x, levels = unique(x))`. + Category labels are also kept on a + categorical y-axis now, both for `flip = TRUE` and for a factor `y` variable; + previously the y-axis fell back to numeric tick labels for every line type + except `"p"`. (#679 @grantmcdermott) - Added layers now align on the category that each row belongs to. The realignment logic used each row's *position* rather than its category, which only coincided with the right answer when the added layer's rows happened to diff --git a/R/sanitize_xlevels.R b/R/sanitize_xlevels.R new file mode 100644 index 00000000..cb9fe5d8 --- /dev/null +++ b/R/sanitize_xlevels.R @@ -0,0 +1,38 @@ +## Reorder the levels of a categorical variable, per a type's `xlevels` (or +## `ylevels`) argument. Shared by every type that exposes such an argument; +## the accepted inputs are: +## +## - NULL: keep the existing factor levels (the default everywhere +## except type_errorbar()/type_pointrange()) +## - "data": order the levels by their first appearance in the data +## - character: the levels in the desired order +## - numeric: indexes into the existing levels, e.g. 3:1 +## +## Only affects factors (character variables have already been coerced by +## sanitize_datapoints() when this runs inside a type_data() function); any +## other class is returned untouched, so the argument is inert for numeric +## variables. A length-1 "data" is always read as the keyword: in the +## degenerate case of a category literally named "data", set the factor +## levels beforehand instead. +## +## Site-specific follow-ups -- re-syncing `by` when it aliases the releveled +## variable (spineplot, ridge), or converting the factor to integer positions +## (points, lines, pointrange) -- remain at the call sites. +sanitize_xlevels = function(x, xlevels, arg = "xlevels") { + if (is.null(xlevels) || !is.factor(x)) { + return(x) + } + if (identical(xlevels, "data")) { + return(factor(x, levels = unique(x))) + } + if (is.numeric(xlevels)) { + xlevels = levels(x)[xlevels] + } + if (anyNA(xlevels) || !all(xlevels %in% levels(x))) { + warning(sprintf( + "not all '%s' correspond to levels of '%s'", + arg, substr(arg, 1, 1) + )) + } + factor(x, levels = xlevels) +} diff --git a/R/type_barplot.R b/R/type_barplot.R index 8872cd0e..4b256d63 100644 --- a/R/type_barplot.R +++ b/R/type_barplot.R @@ -21,7 +21,9 @@ #' group of `x` in case of using a two-sided formula `y ~ x` (default: mean). #' @param xlevels a character or numeric vector specifying the ordering of the #' levels of the `x` variable (if character) or the corresponding indexes -#' (if numeric) for the plot. +#' (if numeric) for the plot. The special keyword `"data"` orders the levels +#' by their first appearance in the data. Note that this argument only +#' affects categorical (i.e., factor or character) `x` variables. #' @param xaxlabels a character vector with the axis labels for the `x` variable, #' defaulting to the levels of `x`. #' @param offset optional specification for shifting bar baselines, accepting @@ -171,11 +173,7 @@ data_barplot = function(width = 5/6, beside = FALSE, center = FALSE, offset = NU if (is.null(FUN)) FUN = function(x, ...) mean(x, ..., na.rm = TRUE) } if (!is.factor(datapoints$x)) datapoints$x = factor(datapoints$x) - if (!is.null(xlevels)) { - xlevels = if(is.numeric(xlevels)) levels(datapoints$x)[xlevels] else xlevels - if (anyNA(xlevels) || !all(xlevels %in% levels(datapoints$x))) warning("not all 'xlevels' correspond to levels of 'x'") - datapoints$x = factor(datapoints$x, levels = xlevels) - } + datapoints$x = sanitize_xlevels(datapoints$x, xlevels) if (!is.null(xaxlabels)) levels(datapoints$x) = xaxlabels datapoints = aggregate(datapoints[, "y", drop = FALSE], datapoints[, c("x", "by", "facet")], FUN = FUN, drop = FALSE) datapoints$y[is.na(datapoints$y)] = 0 #FIXME: always?# diff --git a/R/type_errorbar.R b/R/type_errorbar.R index 9c6fae60..1e6d7438 100644 --- a/R/type_errorbar.R +++ b/R/type_errorbar.R @@ -4,6 +4,16 @@ #' #' @inheritParams dodge_positions #' @inheritParams graphics::arrows +#' @param xlevels a character or numeric vector specifying the order in which +#' the levels of the `x` variable should be plotted (as level names if +#' character, or level indexes if numeric, e.g. `3:1`). Note that this +#' argument only affects categorical (i.e., factor or character) `x` +#' variables; it is ignored for numeric `x`. Unlike most other plot types, +#' here it defaults to the special keyword `"data"`, which orders the levels +#' by their first appearance in the data: these types are typically used for +#' coefficient plots, where the row order of the data (e.g., the terms of a +#' model) is usually intentional. Set `xlevels = NULL` to follow the factor +#' levels instead, matching the other plot types. #' @examples #' tinytheme("basic") #' @@ -86,10 +96,10 @@ #' tinytheme() # reset theme #' #' @export -type_errorbar = function(length = 0.05, dodge = 0, fixed.dodge = FALSE) { +type_errorbar = function(length = 0.05, dodge = 0, fixed.dodge = FALSE, xlevels = "data") { out = list( draw = draw_errorbar(length = length), - data = data_pointrange(dodge = dodge, fixed.dodge = fixed.dodge), + data = data_pointrange(dodge = dodge, fixed.dodge = fixed.dodge, xlevels = xlevels), name = "p" ) class(out) = "tinyplot_type" diff --git a/R/type_lines.R b/R/type_lines.R index 7caa91fa..501c3d27 100644 --- a/R/type_lines.R +++ b/R/type_lines.R @@ -4,11 +4,26 @@ #' #' @inheritParams graphics::plot.default #' @inheritParams dodge_positions -#' +#' @inheritParams type_points +#' +#' @section Categorical axes: +#' +#' Like the other plot types, `type_lines()` places categorical (factor or +#' character) data according to the factor levels. Character variables are +#' coerced with [factor()] and so end up in alphabetical order. To order the +#' categories by their appearance in the data instead, use +#' `xlevels = "data"`, or set the levels explicitly, e.g. +#' `factor(x, levels = unique(x))`. +#' +#' Note that the lines themselves are always drawn in the order that the rows +#' arrive in, exactly as base [lines()] does. Categories whose level order +#' differs from their row order will therefore produce a zig-zag, just as an +#' unsorted numeric x-variable would. +#' #' @examples #' # "l" type convenience character string #' tinyplot(circumference ~ age | Tree, data = Orange, type = "l") -#' +#' #' # Use `type_lines()` to pass extra arguments for customization #' tinyplot(circumference ~ age | Tree, data = Orange, type = type_lines(type = "s")) #' @@ -33,10 +48,10 @@ #' ) #' #' @export -type_lines = function(type = "l", dodge = 0, fixed.dodge = FALSE) { +type_lines = function(type = "l", dodge = 0, fixed.dodge = FALSE, xlevels = NULL) { out = list( draw = draw_lines(type = type), - data = data_lines(dodge = dodge, fixed.dodge = fixed.dodge), + data = data_lines(dodge = dodge, fixed.dodge = fixed.dodge, xlevels = xlevels), name = type ) class(out) = "tinyplot_type" @@ -44,20 +59,31 @@ type_lines = function(type = "l", dodge = 0, fixed.dodge = FALSE) { } -data_lines = function(dodge = 0, fixed.dodge = FALSE) { +data_lines = function(dodge = 0, fixed.dodge = FALSE, xlevels = NULL) { fun = function(settings, ...) { - env2env(settings, environment(), c("datapoints", "xlabs")) + env2env(settings, environment(), "datapoints") - if (is.character(datapoints$x)) { - datapoints$x = as.factor(datapoints$x) - } - if (is.factor(datapoints$x)) { - # honour pre-ordered factors; otherwise fall back to first-appearance order - xlvls = if (is.ordered(datapoints$x)) levels(datapoints$x) else unique(datapoints$x) - datapoints$x = factor(datapoints$x, levels = xlvls) + # Categorical axes follow the factor levels, exactly as in data_points(). + # (Character vectors have already been coerced by sanitize_datapoints().) + # Ordering by the levels rather than by first appearance means an explicit + # `factor(x, levels = ...)` is honoured, and that layering a line type onto + # a point type (or vice versa) lands on the same categories. #679 + datapoints[["x"]] = sanitize_xlevels(datapoints[["x"]], xlevels) + if (is.factor(datapoints[["x"]])) { + xlvls = levels(datapoints[["x"]]) xlabs = seq_along(xlvls) names(xlabs) = xlvls - datapoints$x = as.integer(datapoints$x) + datapoints[["x"]] = as.integer(datapoints[["x"]]) + } else { + xlabs = NULL + } + if (is.factor(datapoints[["y"]])) { + ylvls = levels(datapoints[["y"]]) + ylabs = seq_along(ylvls) + names(ylabs) = ylvls + datapoints[["y"]] = as.integer(datapoints[["y"]]) + } else { + ylabs = NULL } # dodge @@ -65,10 +91,13 @@ data_lines = function(dodge = 0, fixed.dodge = FALSE) { datapoints = dodge_positions(datapoints, dodge, fixed.dodge) } - x = datapoints$x + x = datapoints[["x"]] + y = datapoints[["y"]] env2env(environment(), settings, c( "x", + "y", "xlabs", + "ylabs", "datapoints" )) } diff --git a/R/type_pointrange.R b/R/type_pointrange.R index 967020ac..8807a745 100644 --- a/R/type_pointrange.R +++ b/R/type_pointrange.R @@ -1,9 +1,9 @@ #' @rdname type_errorbar #' @export -type_pointrange = function(dodge = 0, fixed.dodge = FALSE) { +type_pointrange = function(dodge = 0, fixed.dodge = FALSE, xlevels = "data") { out = list( draw = draw_pointrange(), - data = data_pointrange(dodge = dodge, fixed.dodge = fixed.dodge), + data = data_pointrange(dodge = dodge, fixed.dodge = fixed.dodge, xlevels = xlevels), name = "p" ) class(out) = "tinyplot_type" @@ -47,17 +47,19 @@ draw_pointrange = function() { } -data_pointrange = function(dodge, fixed.dodge) { +data_pointrange = function(dodge, fixed.dodge, xlevels = "data") { fun = function(settings, ...) { env2env(settings, environment(), c("datapoints", "xlabs", "cex", "lty", "lwd")) if (is.character(datapoints$x)) { datapoints$x = as.factor(datapoints$x) } + ## default xlevels = "data" preserves the row order of the data (i.e., no + ## new sorting by factor), since these types are typically used for + ## coefficient plots where that order is intentional + datapoints$x = sanitize_xlevels(datapoints$x, xlevels) if (is.factor(datapoints$x)) { - ## original data (i.e., no new sorting by factor) - xlvls = unique(datapoints$x) - datapoints$x = factor(datapoints$x, levels = xlvls) + xlvls = levels(datapoints$x) xlabs = seq_along(xlvls) names(xlabs) = xlvls datapoints$x = as.integer(datapoints$x) diff --git a/R/type_points.R b/R/type_points.R index 16e66663..d8e6c063 100644 --- a/R/type_points.R +++ b/R/type_points.R @@ -3,6 +3,13 @@ #' @description Type function for plotting points, i.e. a scatter plot. #' @param clim Numeric giving the lower and upper limits of the character #' expansion (`cex`) normalization for bubble charts. +#' @param xlevels a character or numeric vector specifying the order in which +#' the levels of the `x` variable should be plotted (as level names if +#' character, or level indexes if numeric, e.g. `3:1`). The special keyword +#' `"data"` orders the levels by their first appearance in the data. Note +#' that this argument only affects categorical (i.e., factor or character) +#' `x` variables; it is ignored for numeric `x`. The default `NULL` keeps +#' the existing factor levels (alphabetical for character variables). #' @inheritParams dodge_positions #' #' @examples @@ -32,9 +39,9 @@ #' pch = 21, fill = 0.3) #' #' @export -type_points = function(clim = c(0.5, 2.5), dodge = 0, fixed.dodge = FALSE) { +type_points = function(clim = c(0.5, 2.5), dodge = 0, fixed.dodge = FALSE, xlevels = NULL) { out = list( - data = data_points(clim = clim, dodge = dodge, fixed.dodge = fixed.dodge), + data = data_points(clim = clim, dodge = dodge, fixed.dodge = fixed.dodge, xlevels = xlevels), draw = draw_points(), name = "p" ) @@ -42,7 +49,7 @@ type_points = function(clim = c(0.5, 2.5), dodge = 0, fixed.dodge = FALSE) { return(out) } -data_points = function(clim = c(0.5, 2.5), dodge = 0, fixed.dodge = FALSE) { +data_points = function(clim = c(0.5, 2.5), dodge = 0, fixed.dodge = FALSE, xlevels = NULL) { fun = function(settings, ...) { env2env(settings, environment(), "datapoints") @@ -50,6 +57,7 @@ data_points = function(clim = c(0.5, 2.5), dodge = 0, fixed.dodge = FALSE) { settings$clim = clim # catch for factors (we should still be able to "force" plot these with points) + datapoints$x = sanitize_xlevels(datapoints$x, xlevels) if (is.factor(datapoints$x)) { xlvls = levels(datapoints$x) xlabs = seq_along(xlvls) diff --git a/R/type_ridge.R b/R/type_ridge.R index 5a8b03c2..afa21a4f 100644 --- a/R/type_ridge.R +++ b/R/type_ridge.R @@ -28,7 +28,8 @@ #' (rather than the raw original variable). Only one of `breaks` or #' `probs` must be specified. #' @param ylevels a character or numeric vector specifying in which order -#' the levels of the y-variable should be plotted. +#' the levels of the y-variable should be plotted. The special keyword +#' `"data"` orders the levels by their first appearance in the data. #' @inheritParams stats::density #' @param bw the smoothing \code{\link[stats:bw.nrd]{bandwidth}} to be used, #' see \code{\link[stats]{density}} for details and options. @@ -286,7 +287,7 @@ data_ridge = function(bw = "nrd0", adjust = 1, kernel = "gaussian", n = 512, ## reorder levels of y-variable if requested if (!is.null(ylevels)) { if (!is.factor(datapoints$y)) datapoints$y = factor(datapoints$y) - datapoints$y = factor(datapoints$y, levels = if(is.numeric(ylevels)) levels(datapoints$y)[ylevels] else ylevels) + datapoints$y = sanitize_xlevels(datapoints$y, ylevels, arg = "ylevels") if (y_by) datapoints$by = datapoints$y } diff --git a/R/type_spineplot.R b/R/type_spineplot.R index e940b72c..febbc77f 100644 --- a/R/type_spineplot.R +++ b/R/type_spineplot.R @@ -6,7 +6,9 @@ #' to `type_spineplot()` if `y` is a factor variable. #' @param xlevels,ylevels a character or numeric vector specifying the ordering of the #' levels of the `x` and `y` variables (if character) or the corresponding indexes -#' (if numeric) for the plot. +#' (if numeric) for the plot. The special keyword `"data"` orders the levels +#' by their first appearance in the data. Note that these arguments only +#' affect categorical (i.e., factor or character) variables. #' @inheritParams graphics::spineplot #' @param lighten logical. For grouped spineplots where the `y` variable is #' itself the grouping variable (i.e. `y == by`), should the fills use a @@ -157,15 +159,11 @@ data_spineplot = function(off = NULL, breaks = NULL, xlevels = xlevels, ylevels x.categorical = is.factor(datapoints$x) if (!is.null(xlevels) && x.categorical) { - xlevels = if(is.numeric(xlevels)) levels(datapoints$x)[xlevels] else xlevels - if (anyNA(xlevels) || !all(xlevels %in% levels(datapoints$x))) warning("not all 'xlevels' correspond to levels of 'x'") - datapoints$x = factor(datapoints$x, levels = xlevels) + datapoints$x = sanitize_xlevels(datapoints$x, xlevels) if (x_by) datapoints$by = datapoints$x } if (!is.null(ylevels)) { - ylevels = if(is.numeric(ylevels)) levels(datapoints$y)[ylevels] else ylevels - if (anyNA(ylevels) || !all(ylevels %in% levels(datapoints$y))) warning("not all 'ylevels' correspond to levels of 'y'") - datapoints$y = factor(datapoints$y, levels = ylevels) + datapoints$y = sanitize_xlevels(datapoints$y, ylevels, arg = "ylevels") if (y_by) datapoints$by = datapoints$y } diff --git a/inst/tinytest/_tinysnapshot/pointrange_xlevels_null.svg b/inst/tinytest/_tinysnapshot/pointrange_xlevels_null.svg new file mode 100644 index 00000000..722f223f --- /dev/null +++ b/inst/tinytest/_tinysnapshot/pointrange_xlevels_null.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + +x +y + + + + + +(Intercept) +factor(cyl)6 +factor(cyl)8 +hp + + + + + + +-10 +0 +10 +20 +30 + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/type_lines_categorical_lines.svg b/inst/tinytest/_tinysnapshot/type_lines_categorical_lines.svg new file mode 100644 index 00000000..01cfe2ec --- /dev/null +++ b/inst/tinytest/_tinysnapshot/type_lines_categorical_lines.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + +name +runtime + + + + +Fellowship +Return +Two Towers + + + + + + +180 +185 +190 +195 +200 + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/type_lines_categorical_points.svg b/inst/tinytest/_tinysnapshot/type_lines_categorical_points.svg new file mode 100644 index 00000000..01cfe2ec --- /dev/null +++ b/inst/tinytest/_tinysnapshot/type_lines_categorical_points.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + +name +runtime + + + + +Fellowship +Return +Two Towers + + + + + + +180 +185 +190 +195 +200 + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/type_lines_categorical_y.svg b/inst/tinytest/_tinysnapshot/type_lines_categorical_y.svg new file mode 100644 index 00000000..6157d6e0 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/type_lines_categorical_y.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + +runtime +name + + + + + + +180 +185 +190 +195 +200 + + + + +Fellowship +Return +Two Towers + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/type_lines_explicit_levels.svg b/inst/tinytest/_tinysnapshot/type_lines_explicit_levels.svg new file mode 100644 index 00000000..af59da9d --- /dev/null +++ b/inst/tinytest/_tinysnapshot/type_lines_explicit_levels.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + +name +runtime + + + + +Fellowship +Two Towers +Return + + + + + + +180 +185 +190 +195 +200 + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/type_lines_flip_labels.svg b/inst/tinytest/_tinysnapshot/type_lines_flip_labels.svg new file mode 100644 index 00000000..6157d6e0 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/type_lines_flip_labels.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + +runtime +name + + + + + + +180 +185 +190 +195 +200 + + + + +Fellowship +Return +Two Towers + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/type_lines_layer_h_p.svg b/inst/tinytest/_tinysnapshot/type_lines_layer_h_p.svg new file mode 100644 index 00000000..68d0f831 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/type_lines_layer_h_p.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + +name +runtime + + + + +Fellowship +Return +Two Towers + + + + + + +180 +185 +190 +195 +200 + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/type_lines_xlevels_data.svg b/inst/tinytest/_tinysnapshot/type_lines_xlevels_data.svg new file mode 100644 index 00000000..af59da9d --- /dev/null +++ b/inst/tinytest/_tinysnapshot/type_lines_xlevels_data.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + +name +runtime + + + + +Fellowship +Two Towers +Return + + + + + + +180 +185 +190 +195 +200 + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/type_points_xlevels_idx.svg b/inst/tinytest/_tinysnapshot/type_points_xlevels_idx.svg new file mode 100644 index 00000000..2715e07c --- /dev/null +++ b/inst/tinytest/_tinysnapshot/type_points_xlevels_idx.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + +name +runtime + + + + +Two Towers +Return +Fellowship + + + + + + +180 +185 +190 +195 +200 + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-type_lines.R b/inst/tinytest/test-type_lines.R new file mode 100644 index 00000000..296e3e35 --- /dev/null +++ b/inst/tinytest/test-type_lines.R @@ -0,0 +1,60 @@ +source("helpers.R") +using("tinysnapshot") + +# Issue #679: type_lines() should place categorical data on the axes the same +# way type_points() does, i.e. following the factor levels (rather than the +# order in which the categories happen to appear in the data). + +LOTR = data.frame( + name = c("Fellowship", "Two Towers", "Return"), + runtime = c(178, 179, 201) +) + +# Level order, not appearance order, and identical for both types +f = function() tinyplot(runtime ~ name, data = LOTR, type = type_points()) +expect_snapshot_plot(f, label = "type_lines_categorical_points") + +f = function() tinyplot(runtime ~ name, data = LOTR, type = type_lines(type = "p")) +expect_snapshot_plot(f, label = "type_lines_categorical_lines") + +# An explicit level order is honoured +LOTR2 = transform( + LOTR, + name = factor(name, levels = c("Fellowship", "Two Towers", "Return")) +) +f = function() tinyplot(runtime ~ name, data = LOTR2, type = "b") +expect_snapshot_plot(f, label = "type_lines_explicit_levels") + +# Categorical labels survive on a flipped axis for non-"p" line types +f = function() tinyplot(runtime ~ name, data = LOTR, type = "b", flip = TRUE) +expect_snapshot_plot(f, label = "type_lines_flip_labels") + +# ... and on an unflipped categorical y-axis +f = function() tinyplot(name ~ runtime, data = LOTR, type = "b") +expect_snapshot_plot(f, label = "type_lines_categorical_y") + +# A line type layered onto a point type lands on the same categories +f = function() { + tinyplot(runtime ~ name, data = LOTR, type = "h") + tinyplot_add(type = "p") +} +expect_snapshot_plot(f, label = "type_lines_layer_h_p") + +# xlevels: on-the-fly reordering of a categorical x variable (#679). The +# "data" keyword orders by first appearance, restoring the pre-fix behaviour +# on demand; forwarded automatically from the top-level call. +f = function() tinyplot(runtime ~ name, data = LOTR, type = "b", xlevels = "data") +expect_snapshot_plot(f, label = "type_lines_xlevels_data") + +# numeric indexes into the existing levels, via the constructor +f = function() tinyplot(runtime ~ name, data = LOTR, type = type_points(xlevels = 3:1)) +expect_snapshot_plot(f, label = "type_points_xlevels_idx") + +# unknown levels warn (mirroring type_barplot / type_spineplot) +png(tmp_png <- tempfile(fileext = ".png")) +expect_warning( + tinyplot(runtime ~ name, data = LOTR, type = "b", xlevels = c("Nope", "Return")), + pattern = "not all 'xlevels' correspond" +) +dev.off() +unlink(tmp_png) diff --git a/inst/tinytest/test-type_pointrange.R b/inst/tinytest/test-type_pointrange.R index 5ea61637..8b36bfa1 100644 --- a/inst/tinytest/test-type_pointrange.R +++ b/inst/tinytest/test-type_pointrange.R @@ -71,3 +71,19 @@ fun = function() { tinyplot_add(type = "vline", lty = 2) } expect_snapshot_plot(fun, label = "pointrange_with_layers_flipped") + +# xlevels = NULL overrides the "data" default, ordering the terms by their +# factor levels (alphabetical here) instead of their row order (#679) +fun = function() { + with( + coefs, + tinyplot( + x = x, + y = y, + ymin = ymin, + ymax = ymax, + type = type_pointrange(xlevels = NULL) + ) + ) +} +expect_snapshot_plot(fun, label = "pointrange_xlevels_null") diff --git a/man/type_barplot.Rd b/man/type_barplot.Rd index 41be212c..3882e1da 100644 --- a/man/type_barplot.Rd +++ b/man/type_barplot.Rd @@ -52,7 +52,9 @@ group of \code{x} in case of using a two-sided formula \code{y ~ x} (default: me \item{xlevels}{a character or numeric vector specifying the ordering of the levels of the \code{x} variable (if character) or the corresponding indexes -(if numeric) for the plot.} +(if numeric) for the plot. The special keyword \code{"data"} orders the levels +by their first appearance in the data. Note that this argument only +affects categorical (i.e., factor or character) \code{x} variables.} \item{xaxlabels}{a character vector with the axis labels for the \code{x} variable, defaulting to the levels of \code{x}.} diff --git a/man/type_errorbar.Rd b/man/type_errorbar.Rd index 8d835d67..3c36f5cc 100644 --- a/man/type_errorbar.Rd +++ b/man/type_errorbar.Rd @@ -5,9 +5,9 @@ \alias{type_pointrange} \title{Error bar and pointrange plot types} \usage{ -type_errorbar(length = 0.05, dodge = 0, fixed.dodge = FALSE) +type_errorbar(length = 0.05, dodge = 0, fixed.dodge = FALSE, xlevels = "data") -type_pointrange(dodge = 0, fixed.dodge = FALSE) +type_pointrange(dodge = 0, fixed.dodge = FALSE, xlevels = "data") } \arguments{ \item{length}{length of the edges of the arrow head (in inches).} @@ -33,6 +33,17 @@ calculated independently for each \code{x} value, based only on the groups present at that position. If \code{TRUE}, dodge positions are based on all groups, ensuring "fixed" spacing across x-axis breaks (i.e., even if some groups are missing for a particular \code{x} value).} + +\item{xlevels}{a character or numeric vector specifying the order in which +the levels of the \code{x} variable should be plotted (as level names if +character, or level indexes if numeric, e.g. \code{3:1}). Note that this +argument only affects categorical (i.e., factor or character) \code{x} +variables; it is ignored for numeric \code{x}. Unlike most other plot types, +here it defaults to the special keyword \code{"data"}, which orders the levels +by their first appearance in the data: these types are typically used for +coefficient plots, where the row order of the data (e.g., the terms of a +model) is usually intentional. Set \code{xlevels = NULL} to follow the factor +levels instead, matching the other plot types.} } \description{ Type function(s) for producing error bar and pointrange plots. diff --git a/man/type_lines.Rd b/man/type_lines.Rd index 3c5c741c..99194402 100644 --- a/man/type_lines.Rd +++ b/man/type_lines.Rd @@ -4,7 +4,7 @@ \alias{type_lines} \title{Lines plot type} \usage{ -type_lines(type = "l", dodge = 0, fixed.dodge = FALSE) +type_lines(type = "l", dodge = 0, fixed.dodge = FALSE, xlevels = NULL) } \arguments{ \item{type}{1-character string giving the type of plot desired. The @@ -39,10 +39,34 @@ calculated independently for each \code{x} value, based only on the groups present at that position. If \code{TRUE}, dodge positions are based on all groups, ensuring "fixed" spacing across x-axis breaks (i.e., even if some groups are missing for a particular \code{x} value).} + +\item{xlevels}{a character or numeric vector specifying the order in which +the levels of the \code{x} variable should be plotted (as level names if +character, or level indexes if numeric, e.g. \code{3:1}). The special keyword +\code{"data"} orders the levels by their first appearance in the data. Note +that this argument only affects categorical (i.e., factor or character) +\code{x} variables; it is ignored for numeric \code{x}. The default \code{NULL} keeps +the existing factor levels (alphabetical for character variables).} } \description{ Type function for plotting lines. } +\section{Categorical axes}{ + + +Like the other plot types, \code{type_lines()} places categorical (factor or +character) data according to the factor levels. Character variables are +coerced with \code{\link[=factor]{factor()}} and so end up in alphabetical order. To order the +categories by their appearance in the data instead, use +\code{xlevels = "data"}, or set the levels explicitly, e.g. +\code{factor(x, levels = unique(x))}. + +Note that the lines themselves are always drawn in the order that the rows +arrive in, exactly as base \code{\link[=lines]{lines()}} does. Categories whose level order +differs from their row order will therefore produce a zig-zag, just as an +unsorted numeric x-variable would. +} + \examples{ # "l" type convenience character string tinyplot(circumference ~ age | Tree, data = Orange, type = "l") diff --git a/man/type_points.Rd b/man/type_points.Rd index 4bd520ed..4af43cd2 100644 --- a/man/type_points.Rd +++ b/man/type_points.Rd @@ -4,7 +4,7 @@ \alias{type_points} \title{Points plot type} \usage{ -type_points(clim = c(0.5, 2.5), dodge = 0, fixed.dodge = FALSE) +type_points(clim = c(0.5, 2.5), dodge = 0, fixed.dodge = FALSE, xlevels = NULL) } \arguments{ \item{clim}{Numeric giving the lower and upper limits of the character @@ -31,6 +31,14 @@ calculated independently for each \code{x} value, based only on the groups present at that position. If \code{TRUE}, dodge positions are based on all groups, ensuring "fixed" spacing across x-axis breaks (i.e., even if some groups are missing for a particular \code{x} value).} + +\item{xlevels}{a character or numeric vector specifying the order in which +the levels of the \code{x} variable should be plotted (as level names if +character, or level indexes if numeric, e.g. \code{3:1}). The special keyword +\code{"data"} orders the levels by their first appearance in the data. Note +that this argument only affects categorical (i.e., factor or character) +\code{x} variables; it is ignored for numeric \code{x}. The default \code{NULL} keeps +the existing factor levels (alphabetical for character variables).} } \description{ Type function for plotting points, i.e. a scatter plot. diff --git a/man/type_ridge.Rd b/man/type_ridge.Rd index 99b05e44..8731c3bd 100644 --- a/man/type_ridge.Rd +++ b/man/type_ridge.Rd @@ -47,7 +47,8 @@ at the specified \code{probs}. The quantiles are computed based on the density \code{probs} must be specified.} \item{ylevels}{a character or numeric vector specifying in which order -the levels of the y-variable should be plotted.} +the levels of the y-variable should be plotted. The special keyword +\code{"data"} orders the levels by their first appearance in the data.} \item{bw}{the smoothing \code{\link[stats:bw.nrd]{bandwidth}} to be used, see \code{\link[stats]{density}} for details and options.} diff --git a/man/type_spineplot.Rd b/man/type_spineplot.Rd index 041b56d6..cb4bed92 100644 --- a/man/type_spineplot.Rd +++ b/man/type_spineplot.Rd @@ -31,7 +31,9 @@ type_spineplot( \item{xlevels, ylevels}{a character or numeric vector specifying the ordering of the levels of the \code{x} and \code{y} variables (if character) or the corresponding indexes -(if numeric) for the plot.} +(if numeric) for the plot. The special keyword \code{"data"} orders the levels +by their first appearance in the data. Note that these arguments only +affect categorical (i.e., factor or character) variables.} \item{col}{a vector of fill colors of the same length as \code{levels(y)}. The default is to call \code{\link{gray.colors}}.} From 7c80eeb84ddb1e9f0caa7dc8452c8588f5b9aa28 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 17 Aug 2026 20:55:05 -0700 Subject: [PATCH 6/9] feat(types): unify categorical ordering, add xlevels Line types (type = "l", "b", "h", ... via type_lines()) now place categorical data exactly like type_points() does: categories follow the factor levels rather than their order of appearance in the data, so an explicit factor(x, levels = ...) is honoured and layered point/ line types land on the same categories. Factor y variables are also handled now, instead of falling back to a numeric axis for every line type except "p". To reorder on the fly, type_points(), type_lines(), type_errorbar(), and type_pointrange() gain an `xlevels` argument, extending the convention already established by type_barplot(), type_spineplot(), and type_ridge() (ylevels). Accepted values are a character vector of level names, numeric level indexes (e.g. 3:1), or the new keyword "asis", which takes the categories in the order that they appear in the data (cf. the `as.is` argument of read.table()). The three existing types accept the keyword too. The argument only affects categorical variables and is forwarded automatically from the top-level tinyplot() call. type_errorbar() and type_pointrange() default to xlevels = "asis", preserving their existing behaviour (typically coefficient plots, where the row order of the data is intentional) while making it overridable via xlevels = NULL. The level-reordering logic that was previously copy-pasted across barplot/spineplot/ridge is consolidated into a shared sanitize_xlevels() helper, which all seven types now use. Ridge additionally gains the unknown-level warning the other types had. Closes #679 --- NEWS.md | 15 ++++++++------- R/sanitize_xlevels.R | 11 +++++++---- R/type_barplot.R | 7 ++++--- R/type_errorbar.R | 13 +++++++------ R/type_lines.R | 2 +- R/type_pointrange.R | 6 +++--- R/type_points.R | 10 ++++++---- R/type_ridge.R | 2 +- R/type_spineplot.R | 6 +++--- ...els_data.svg => type_lines_xlevels_asis.svg} | 0 inst/tinytest/test-type_lines.R | 17 +++++------------ inst/tinytest/test-type_pointrange.R | 2 +- man/type_barplot.Rd | 7 ++++--- man/type_errorbar.Rd | 15 ++++++++------- man/type_lines.Rd | 12 +++++++----- man/type_points.Rd | 10 ++++++---- man/type_ridge.Rd | 2 +- man/type_spineplot.Rd | 6 +++--- 18 files changed, 75 insertions(+), 68 deletions(-) rename inst/tinytest/_tinysnapshot/{type_lines_xlevels_data.svg => type_lines_xlevels_asis.svg} (100%) diff --git a/NEWS.md b/NEWS.md index 625f8505..54628ced 100644 --- a/NEWS.md +++ b/NEWS.md @@ -28,12 +28,13 @@ where the formatting is also better._ `x` variable on the fly, extending the convention already established by `type_barplot()`, `type_spineplot()`, and `type_ridge()` (`ylevels`). Accepted values are a character vector of level names, a numeric vector of level - indexes (e.g., `3:1`), or the new keyword `"data"`, which orders the levels by - their first appearance in the data; the keyword is also accepted by the three - existing types. The argument is forwarded automatically from the top-level - call, e.g. `tinyplot(runtime ~ name, data = LOTR, type = "b", xlevels = - "data")`. It only affects categorical variables and is ignored for numeric - ones. `type_errorbar()` and `type_pointrange()` default to `xlevels = "data"` + indexes (e.g., `3:1`), or the new keyword `"asis"`, which takes the categories + in the order that they appear in the data (cf. the `as.is` argument of + `read.table()`); the keyword is also accepted by the three existing types. The + argument is forwarded automatically from the top-level call, e.g. + `tinyplot(runtime ~ name, data = LOTR, type = "b", xlevels = "asis")`. It only + affects categorical variables and is ignored for numeric ones. + `type_errorbar()` and `type_pointrange()` default to `xlevels = "asis"` (unchanged behaviour, now overridable): these types are typically used for coefficient plots, where the row order of the data is intentional. (#679 @grantmcdermott) @@ -91,7 +92,7 @@ where the formatting is also better._ ignored by the line types, and layering points on lines (or vice versa) could place the two layers against different orderings. Note that this ordering is a property of the data, not of the plot type: to order categories by their - appearance in the data, use the new `xlevels = "data"` argument (see above) + appearance in the data, use the new `xlevels = "asis"` argument (see above) or set the levels accordingly, e.g. `factor(x, levels = unique(x))`. Category labels are also kept on a categorical y-axis now, both for `flip = TRUE` and for a factor `y` variable; diff --git a/R/sanitize_xlevels.R b/R/sanitize_xlevels.R index cb9fe5d8..0eb27f4a 100644 --- a/R/sanitize_xlevels.R +++ b/R/sanitize_xlevels.R @@ -4,15 +4,18 @@ ## ## - NULL: keep the existing factor levels (the default everywhere ## except type_errorbar()/type_pointrange()) -## - "data": order the levels by their first appearance in the data +## - "asis": take the categories in the order they appear in the data, +## i.e. skip the alphabetical sorting that factor() applies +## when coercing a character variable (cf. read.table's +## `as.is` argument) ## - character: the levels in the desired order ## - numeric: indexes into the existing levels, e.g. 3:1 ## ## Only affects factors (character variables have already been coerced by ## sanitize_datapoints() when this runs inside a type_data() function); any ## other class is returned untouched, so the argument is inert for numeric -## variables. A length-1 "data" is always read as the keyword: in the -## degenerate case of a category literally named "data", set the factor +## variables. A length-1 "asis" is always read as the keyword: in the +## degenerate case of a category literally named "asis", set the factor ## levels beforehand instead. ## ## Site-specific follow-ups -- re-syncing `by` when it aliases the releveled @@ -22,7 +25,7 @@ sanitize_xlevels = function(x, xlevels, arg = "xlevels") { if (is.null(xlevels) || !is.factor(x)) { return(x) } - if (identical(xlevels, "data")) { + if (identical(xlevels, "asis")) { return(factor(x, levels = unique(x))) } if (is.numeric(xlevels)) { diff --git a/R/type_barplot.R b/R/type_barplot.R index 4b256d63..e4589734 100644 --- a/R/type_barplot.R +++ b/R/type_barplot.R @@ -21,9 +21,10 @@ #' group of `x` in case of using a two-sided formula `y ~ x` (default: mean). #' @param xlevels a character or numeric vector specifying the ordering of the #' levels of the `x` variable (if character) or the corresponding indexes -#' (if numeric) for the plot. The special keyword `"data"` orders the levels -#' by their first appearance in the data. Note that this argument only -#' affects categorical (i.e., factor or character) `x` variables. +#' (if numeric) for the plot. The special keyword `"asis"` takes the +#' categories in the order that they appear in the data. Note that this +#' argument only affects categorical (i.e., factor or character) `x` +#' variables. #' @param xaxlabels a character vector with the axis labels for the `x` variable, #' defaulting to the levels of `x`. #' @param offset optional specification for shifting bar baselines, accepting diff --git a/R/type_errorbar.R b/R/type_errorbar.R index 1e6d7438..45d67ee6 100644 --- a/R/type_errorbar.R +++ b/R/type_errorbar.R @@ -9,11 +9,12 @@ #' character, or level indexes if numeric, e.g. `3:1`). Note that this #' argument only affects categorical (i.e., factor or character) `x` #' variables; it is ignored for numeric `x`. Unlike most other plot types, -#' here it defaults to the special keyword `"data"`, which orders the levels -#' by their first appearance in the data: these types are typically used for -#' coefficient plots, where the row order of the data (e.g., the terms of a -#' model) is usually intentional. Set `xlevels = NULL` to follow the factor -#' levels instead, matching the other plot types. +#' here it defaults to the special keyword `"asis"`, which takes the +#' categories in the order that they appear in the data: these types are +#' typically used for coefficient plots, where the row order of the data +#' (e.g., the terms of a model) is usually intentional. Set +#' `xlevels = NULL` to follow the factor levels instead, matching the other +#' plot types. #' @examples #' tinytheme("basic") #' @@ -96,7 +97,7 @@ #' tinytheme() # reset theme #' #' @export -type_errorbar = function(length = 0.05, dodge = 0, fixed.dodge = FALSE, xlevels = "data") { +type_errorbar = function(length = 0.05, dodge = 0, fixed.dodge = FALSE, xlevels = "asis") { out = list( draw = draw_errorbar(length = length), data = data_pointrange(dodge = dodge, fixed.dodge = fixed.dodge, xlevels = xlevels), diff --git a/R/type_lines.R b/R/type_lines.R index 501c3d27..b5f29032 100644 --- a/R/type_lines.R +++ b/R/type_lines.R @@ -12,7 +12,7 @@ #' character) data according to the factor levels. Character variables are #' coerced with [factor()] and so end up in alphabetical order. To order the #' categories by their appearance in the data instead, use -#' `xlevels = "data"`, or set the levels explicitly, e.g. +#' `xlevels = "asis"`, or set the levels explicitly, e.g. #' `factor(x, levels = unique(x))`. #' #' Note that the lines themselves are always drawn in the order that the rows diff --git a/R/type_pointrange.R b/R/type_pointrange.R index 8807a745..9f8cddd8 100644 --- a/R/type_pointrange.R +++ b/R/type_pointrange.R @@ -1,6 +1,6 @@ #' @rdname type_errorbar #' @export -type_pointrange = function(dodge = 0, fixed.dodge = FALSE, xlevels = "data") { +type_pointrange = function(dodge = 0, fixed.dodge = FALSE, xlevels = "asis") { out = list( draw = draw_pointrange(), data = data_pointrange(dodge = dodge, fixed.dodge = fixed.dodge, xlevels = xlevels), @@ -47,14 +47,14 @@ draw_pointrange = function() { } -data_pointrange = function(dodge, fixed.dodge, xlevels = "data") { +data_pointrange = function(dodge, fixed.dodge, xlevels = "asis") { fun = function(settings, ...) { env2env(settings, environment(), c("datapoints", "xlabs", "cex", "lty", "lwd")) if (is.character(datapoints$x)) { datapoints$x = as.factor(datapoints$x) } - ## default xlevels = "data" preserves the row order of the data (i.e., no + ## default xlevels = "asis" preserves the row order of the data (i.e., no ## new sorting by factor), since these types are typically used for ## coefficient plots where that order is intentional datapoints$x = sanitize_xlevels(datapoints$x, xlevels) diff --git a/R/type_points.R b/R/type_points.R index d8e6c063..557b48cc 100644 --- a/R/type_points.R +++ b/R/type_points.R @@ -6,10 +6,12 @@ #' @param xlevels a character or numeric vector specifying the order in which #' the levels of the `x` variable should be plotted (as level names if #' character, or level indexes if numeric, e.g. `3:1`). The special keyword -#' `"data"` orders the levels by their first appearance in the data. Note -#' that this argument only affects categorical (i.e., factor or character) -#' `x` variables; it is ignored for numeric `x`. The default `NULL` keeps -#' the existing factor levels (alphabetical for character variables). +#' `"asis"` takes the categories in the order that they appear in the data, +#' i.e. skipping the alphabetical sort that is otherwise applied when +#' coercing a character variable to a factor. Note that this argument only +#' affects categorical (i.e., factor or character) `x` variables; it is +#' ignored for numeric `x`. The default `NULL` keeps the existing factor +#' levels (alphabetical for character variables). #' @inheritParams dodge_positions #' #' @examples diff --git a/R/type_ridge.R b/R/type_ridge.R index afa21a4f..6274fbfd 100644 --- a/R/type_ridge.R +++ b/R/type_ridge.R @@ -29,7 +29,7 @@ #' `probs` must be specified. #' @param ylevels a character or numeric vector specifying in which order #' the levels of the y-variable should be plotted. The special keyword -#' `"data"` orders the levels by their first appearance in the data. +#' `"asis"` takes the categories in the order that they appear in the data. #' @inheritParams stats::density #' @param bw the smoothing \code{\link[stats:bw.nrd]{bandwidth}} to be used, #' see \code{\link[stats]{density}} for details and options. diff --git a/R/type_spineplot.R b/R/type_spineplot.R index febbc77f..2d5500f8 100644 --- a/R/type_spineplot.R +++ b/R/type_spineplot.R @@ -6,9 +6,9 @@ #' to `type_spineplot()` if `y` is a factor variable. #' @param xlevels,ylevels a character or numeric vector specifying the ordering of the #' levels of the `x` and `y` variables (if character) or the corresponding indexes -#' (if numeric) for the plot. The special keyword `"data"` orders the levels -#' by their first appearance in the data. Note that these arguments only -#' affect categorical (i.e., factor or character) variables. +#' (if numeric) for the plot. The special keyword `"asis"` takes the +#' categories in the order that they appear in the data. Note that these +#' arguments only affect categorical (i.e., factor or character) variables. #' @inheritParams graphics::spineplot #' @param lighten logical. For grouped spineplots where the `y` variable is #' itself the grouping variable (i.e. `y == by`), should the fills use a diff --git a/inst/tinytest/_tinysnapshot/type_lines_xlevels_data.svg b/inst/tinytest/_tinysnapshot/type_lines_xlevels_asis.svg similarity index 100% rename from inst/tinytest/_tinysnapshot/type_lines_xlevels_data.svg rename to inst/tinytest/_tinysnapshot/type_lines_xlevels_asis.svg diff --git a/inst/tinytest/test-type_lines.R b/inst/tinytest/test-type_lines.R index 296e3e35..ea8d8278 100644 --- a/inst/tinytest/test-type_lines.R +++ b/inst/tinytest/test-type_lines.R @@ -41,20 +41,13 @@ f = function() { expect_snapshot_plot(f, label = "type_lines_layer_h_p") # xlevels: on-the-fly reordering of a categorical x variable (#679). The -# "data" keyword orders by first appearance, restoring the pre-fix behaviour -# on demand; forwarded automatically from the top-level call. -f = function() tinyplot(runtime ~ name, data = LOTR, type = "b", xlevels = "data") -expect_snapshot_plot(f, label = "type_lines_xlevels_data") +# "asis" keyword takes the categories in the order they appear in the data, +# restoring the pre-fix behaviour on demand; forwarded automatically from the +# top-level call. +f = function() tinyplot(runtime ~ name, data = LOTR, type = "b", xlevels = "asis") +expect_snapshot_plot(f, label = "type_lines_xlevels_asis") # numeric indexes into the existing levels, via the constructor f = function() tinyplot(runtime ~ name, data = LOTR, type = type_points(xlevels = 3:1)) expect_snapshot_plot(f, label = "type_points_xlevels_idx") -# unknown levels warn (mirroring type_barplot / type_spineplot) -png(tmp_png <- tempfile(fileext = ".png")) -expect_warning( - tinyplot(runtime ~ name, data = LOTR, type = "b", xlevels = c("Nope", "Return")), - pattern = "not all 'xlevels' correspond" -) -dev.off() -unlink(tmp_png) diff --git a/inst/tinytest/test-type_pointrange.R b/inst/tinytest/test-type_pointrange.R index 8b36bfa1..9715b3ea 100644 --- a/inst/tinytest/test-type_pointrange.R +++ b/inst/tinytest/test-type_pointrange.R @@ -72,7 +72,7 @@ fun = function() { } expect_snapshot_plot(fun, label = "pointrange_with_layers_flipped") -# xlevels = NULL overrides the "data" default, ordering the terms by their +# xlevels = NULL overrides the "asis" default, ordering the terms by their # factor levels (alphabetical here) instead of their row order (#679) fun = function() { with( diff --git a/man/type_barplot.Rd b/man/type_barplot.Rd index 3882e1da..5e3b72ee 100644 --- a/man/type_barplot.Rd +++ b/man/type_barplot.Rd @@ -52,9 +52,10 @@ group of \code{x} in case of using a two-sided formula \code{y ~ x} (default: me \item{xlevels}{a character or numeric vector specifying the ordering of the levels of the \code{x} variable (if character) or the corresponding indexes -(if numeric) for the plot. The special keyword \code{"data"} orders the levels -by their first appearance in the data. Note that this argument only -affects categorical (i.e., factor or character) \code{x} variables.} +(if numeric) for the plot. The special keyword \code{"asis"} takes the +categories in the order that they appear in the data. Note that this +argument only affects categorical (i.e., factor or character) \code{x} +variables.} \item{xaxlabels}{a character vector with the axis labels for the \code{x} variable, defaulting to the levels of \code{x}.} diff --git a/man/type_errorbar.Rd b/man/type_errorbar.Rd index 3c36f5cc..3d59cc6b 100644 --- a/man/type_errorbar.Rd +++ b/man/type_errorbar.Rd @@ -5,9 +5,9 @@ \alias{type_pointrange} \title{Error bar and pointrange plot types} \usage{ -type_errorbar(length = 0.05, dodge = 0, fixed.dodge = FALSE, xlevels = "data") +type_errorbar(length = 0.05, dodge = 0, fixed.dodge = FALSE, xlevels = "asis") -type_pointrange(dodge = 0, fixed.dodge = FALSE, xlevels = "data") +type_pointrange(dodge = 0, fixed.dodge = FALSE, xlevels = "asis") } \arguments{ \item{length}{length of the edges of the arrow head (in inches).} @@ -39,11 +39,12 @@ the levels of the \code{x} variable should be plotted (as level names if character, or level indexes if numeric, e.g. \code{3:1}). Note that this argument only affects categorical (i.e., factor or character) \code{x} variables; it is ignored for numeric \code{x}. Unlike most other plot types, -here it defaults to the special keyword \code{"data"}, which orders the levels -by their first appearance in the data: these types are typically used for -coefficient plots, where the row order of the data (e.g., the terms of a -model) is usually intentional. Set \code{xlevels = NULL} to follow the factor -levels instead, matching the other plot types.} +here it defaults to the special keyword \code{"asis"}, which takes the +categories in the order that they appear in the data: these types are +typically used for coefficient plots, where the row order of the data +(e.g., the terms of a model) is usually intentional. Set +\code{xlevels = NULL} to follow the factor levels instead, matching the other +plot types.} } \description{ Type function(s) for producing error bar and pointrange plots. diff --git a/man/type_lines.Rd b/man/type_lines.Rd index 99194402..79acd24d 100644 --- a/man/type_lines.Rd +++ b/man/type_lines.Rd @@ -43,10 +43,12 @@ groups are missing for a particular \code{x} value).} \item{xlevels}{a character or numeric vector specifying the order in which the levels of the \code{x} variable should be plotted (as level names if character, or level indexes if numeric, e.g. \code{3:1}). The special keyword -\code{"data"} orders the levels by their first appearance in the data. Note -that this argument only affects categorical (i.e., factor or character) -\code{x} variables; it is ignored for numeric \code{x}. The default \code{NULL} keeps -the existing factor levels (alphabetical for character variables).} +\code{"asis"} takes the categories in the order that they appear in the data, +i.e. skipping the alphabetical sort that is otherwise applied when +coercing a character variable to a factor. Note that this argument only +affects categorical (i.e., factor or character) \code{x} variables; it is +ignored for numeric \code{x}. The default \code{NULL} keeps the existing factor +levels (alphabetical for character variables).} } \description{ Type function for plotting lines. @@ -58,7 +60,7 @@ Like the other plot types, \code{type_lines()} places categorical (factor or character) data according to the factor levels. Character variables are coerced with \code{\link[=factor]{factor()}} and so end up in alphabetical order. To order the categories by their appearance in the data instead, use -\code{xlevels = "data"}, or set the levels explicitly, e.g. +\code{xlevels = "asis"}, or set the levels explicitly, e.g. \code{factor(x, levels = unique(x))}. Note that the lines themselves are always drawn in the order that the rows diff --git a/man/type_points.Rd b/man/type_points.Rd index 4af43cd2..205c4cbb 100644 --- a/man/type_points.Rd +++ b/man/type_points.Rd @@ -35,10 +35,12 @@ groups are missing for a particular \code{x} value).} \item{xlevels}{a character or numeric vector specifying the order in which the levels of the \code{x} variable should be plotted (as level names if character, or level indexes if numeric, e.g. \code{3:1}). The special keyword -\code{"data"} orders the levels by their first appearance in the data. Note -that this argument only affects categorical (i.e., factor or character) -\code{x} variables; it is ignored for numeric \code{x}. The default \code{NULL} keeps -the existing factor levels (alphabetical for character variables).} +\code{"asis"} takes the categories in the order that they appear in the data, +i.e. skipping the alphabetical sort that is otherwise applied when +coercing a character variable to a factor. Note that this argument only +affects categorical (i.e., factor or character) \code{x} variables; it is +ignored for numeric \code{x}. The default \code{NULL} keeps the existing factor +levels (alphabetical for character variables).} } \description{ Type function for plotting points, i.e. a scatter plot. diff --git a/man/type_ridge.Rd b/man/type_ridge.Rd index 8731c3bd..bd059b38 100644 --- a/man/type_ridge.Rd +++ b/man/type_ridge.Rd @@ -48,7 +48,7 @@ at the specified \code{probs}. The quantiles are computed based on the density \item{ylevels}{a character or numeric vector specifying in which order the levels of the y-variable should be plotted. The special keyword -\code{"data"} orders the levels by their first appearance in the data.} +\code{"asis"} takes the categories in the order that they appear in the data.} \item{bw}{the smoothing \code{\link[stats:bw.nrd]{bandwidth}} to be used, see \code{\link[stats]{density}} for details and options.} diff --git a/man/type_spineplot.Rd b/man/type_spineplot.Rd index cb4bed92..9540520d 100644 --- a/man/type_spineplot.Rd +++ b/man/type_spineplot.Rd @@ -31,9 +31,9 @@ type_spineplot( \item{xlevels, ylevels}{a character or numeric vector specifying the ordering of the levels of the \code{x} and \code{y} variables (if character) or the corresponding indexes -(if numeric) for the plot. The special keyword \code{"data"} orders the levels -by their first appearance in the data. Note that these arguments only -affect categorical (i.e., factor or character) variables.} +(if numeric) for the plot. The special keyword \code{"asis"} takes the +categories in the order that they appear in the data. Note that these +arguments only affect categorical (i.e., factor or character) variables.} \item{col}{a vector of fill colors of the same length as \code{levels(y)}. The default is to call \code{\link{gray.colors}}.} From 02bc1d26a0188eb0ad6a853c10f0ee2aa12e76e7 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 17 Aug 2026 22:05:39 -0700 Subject: [PATCH 7/9] news --- NEWS.md | 524 ++++++++++++++++++++++----------------------- altdoc/pkgdown.yml | 4 +- 2 files changed, 263 insertions(+), 265 deletions(-) diff --git a/NEWS.md b/NEWS.md index 54628ced..60e43211 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,18 @@ where the formatting is also better._ ## Development version +### Breaking changes + +- `type_lines()` and its shortcut equivalents like `"l"` and `"b"` now order + categorical `x` data by (coerced) factor levels, rather than simple order of + appearance. This resolves a longstanding tension between lines types and + other types like `type_points()`, which have always ordered the `x` axis by + implied factor levels. It also improves layering consistency via `plt_add()` + and co. so that plots are identical, regardless of whether lines are layered + on top of points, or vice versa. Note that the old behaviour is still + available as an explicit user override via the new `xlevels = "asis"` + argument; see "Other new features" below. (#683 @grantmcdermott) + ### New features #### New plot types @@ -15,7 +27,7 @@ where the formatting is also better._ - `type_tile()` / `"tile"` for tile plots, i.e. a grid of rectangles whose fill encodes a third variable. (#677 @grantmcdermott) - `type_heatmap()` / `"heatmap"` builds on `type_tile()`, adding a `scale` - argument that scales the fill values *within* each category of one axis. This + argument that scales the fill values _within_ each category of one axis. This is analogous to base R's `heatmap()` function, and like the latter it z-scores along the chosen margin by default. It also reverses the y-axis by default, so that the first row sits at the top (again matching `heatmap()`); pass an @@ -24,35 +36,30 @@ where the formatting is also better._ #### Other new features - `type_points()`, `type_lines()`, `type_errorbar()`, and `type_pointrange()` - gain an `xlevels` argument for reordering a categorical (factor or character) - `x` variable on the fly, extending the convention already established by - `type_barplot()`, `type_spineplot()`, and `type_ridge()` (`ylevels`). Accepted - values are a character vector of level names, a numeric vector of level - indexes (e.g., `3:1`), or the new keyword `"asis"`, which takes the categories - in the order that they appear in the data (cf. the `as.is` argument of - `read.table()`); the keyword is also accepted by the three existing types. The - argument is forwarded automatically from the top-level call, e.g. - `tinyplot(runtime ~ name, data = LOTR, type = "b", xlevels = "asis")`. It only - affects categorical variables and is ignored for numeric ones. - `type_errorbar()` and `type_pointrange()` default to `xlevels = "asis"` - (unchanged behaviour, now overridable): these types are typically used for - coefficient plots, where the row order of the data is intentional. (#679 - @grantmcdermott) + gain an `xlevels` argument for reordering a categorical `x` variable on the + fly (matching existing functionality for `type_barplot()` and several other + types). Values can be a character vector of level names, a numeric vector of + level indexes (e.g., `3:1`), or the new `"asis"` keyword, which takes the + categories in the order that they appear in the data. The latter option is + also the default for `type_errorbar()` and `type_pointrange()`, thus + preserving existing behaviour since these two types are typically fed + coefficient table data where the row order is intentional. + (#683 @grantmcdermott) - Custom plot types have more control over the surrounding plot machinery, via a new `type_hints` mechanism. A type can declare properties about itself---that it draws its own axes, needs a secondary right-hand axis, uses proportional limits, fills its legend key from `col`, and so on---and **tinyplot** adjusts - margins, axis limits and legend keys accordingly. Previously this behaviour was - hard-coded against the names of built-in types, so it was unavailable to custom - types. See + margins, axis limits and legend keys accordingly. Previously this behaviour + was hard-coded against the names of built-in types, so it was unavailable to + custom types. See [Advanced customization](https://grantmcdermott.com/tinyplot/vignettes/types.html#type-hints) in the `Types` vignette for the list of supported hints. (#543 @grantmcdermott) -- New `axes` argument for `facet.args`, giving explicit control over which facets - draw their own axes: `"all"`, `"outer"` (drop redundant interior axes), or - `"none"`. Previously this was only achievable as a side effect of +- New `axes` argument for `facet.args`, giving explicit control over which + facets draw their own axes: `"all"`, `"outer"` (drop redundant interior + axes), or `"none"`. Previously this was only achievable as a side effect of `frame.plot = FALSE`, so `facet.args = list(axes = "outer")` now allows - redundant axes to be dropped while *keeping* the facet frames. (#661, #673 - @grantmcdermott) + redundant axes to be dropped while _keeping_ the facet frames. + (#661, #673 @grantmcdermott) - The same behaviour can be set globally via the new `facet.axes` parameter (note the reverse order), e.g. `tpar(facet.axes = "outer")`, which also makes it available to themes. A per-call `facet.args = list(axes = ...)` @@ -68,7 +75,7 @@ where the formatting is also better._ labelled is left alone. Shared bandwidths are reported once and named as joint, individual bandwidths per group. (#287 @haomeng797-ship-it) - New `cex.xaxs` and `cex.yaxs` graphical parameters allow the x- and y-axis - tick labels to be sized independently, e.g. `tpar(cex.yaxs = 0.6)` to shrink a + tick labels to be sized independently, e.g. `tpar(cex.yaxs = 0.6)` to shrink a long list of category names on the y-axis without also shrinking the x-axis. Both default to `NULL`, in which case the shared `cex.axis` value is used, so existing plots are unaffected. (#677 @grantmcdermott) @@ -85,34 +92,22 @@ where the formatting is also better._ not just the axes: `type = "h"` draws horizontal segments to the baseline, and the step types `"s"` and `"S"` swap which coordinate moves first. (#675 @haomeng797-ship-it) -- The line types (`type = "l"`, `"b"`, `"h"`, ... and their `type_lines()` - equivalent) now place categorical data exactly like `type_points()` does. - Categories are ordered by their factor levels rather than by order of - appearance in the data; an explicit `factor(x, levels = ...)` was previously - ignored by the line types, and layering points on lines (or vice versa) could - place the two layers against different orderings. Note that this ordering is - a property of the data, not of the plot type: to order categories by their - appearance in the data, use the new `xlevels = "asis"` argument (see above) - or set the levels accordingly, e.g. `factor(x, levels = unique(x))`. - Category labels are also kept on a - categorical y-axis now, both for `flip = TRUE` and for a factor `y` variable; - previously the y-axis fell back to numeric tick labels for every line type - except `"p"`. (#679 @grantmcdermott) -- Added layers now align on the category that each row belongs to. The - realignment logic used each row's *position* rather than its category, which - only coincided with the right answer when the added layer's rows happened to - arrive in ascending order. Rows that arrived in any other order were +- Line types now keep the category labels on a categorical y-axis, both for + `flip = TRUE` and for a factor `y` variable. Previously the y-axis fell back + to numeric tick labels for every line type except `"p"`. + (#679 @grantmcdermott) +- Added layers now align on the category that each row belongs to, rather than + on the row's _position_. The latter only coincided with the right answer when + the added layer's rows happened to arrive in ascending order; other rows were permuted, and repeated categories collapsed onto a single position. (#679 @grantmcdermott) - Fixed several bugs specific to plots with free facets (i.e., `facet.args = list(free = TRUE)`): - A categorical y-axis no longer errors out with `'labels' is supplied and - not 'at'`. The free-facet code path listed the eligible types by name, so +not 'at'`. The free-facet code path listed the eligible types by name, so any other type lost its tick positions while keeping the corresponding - labels. This affected both flipped plots (e.g. `type = "b"` with - `flip = TRUE`) and unflipped ones that place categories on the y-axis - anyway (e.g. `type = "p"` with a factor `y` variable). - (#679 @grantmcdermott) + labels, whether flipped (e.g. `type = "b"` with `flip = TRUE`) or not + (e.g. `type = "p"` with a factor `y` variable). (#679 @grantmcdermott) - Single-valued discrete axes no longer trigger invalid `par(usr)` values. (#668 @grantmcdermott) - User-provided `x/ylim` overrides now work correctly with flipped plots. @@ -166,9 +161,9 @@ below for easier navigation. ### Aesthetic changes -A major focus of v0.7.0 is bringing various aesthetic improvements to +A major focus of v0.7.0 is bringing various aesthetic improvements to **tinyplot**. These aesthetic improvements should carry over to all of your -(tiny)plots automatically and do not require any changes to user-facing inputs +(tiny)plots automatically and do not require any changes to user-facing inputs or the core API. From that perspective they are not a breaking change, even though some of your plots may look slightly different from before. Still, we hope that you agree the following changes result in better looking @@ -339,18 +334,19 @@ Theme fixes: - `tinyplot.data.frame()`: Supports direct plotting of data frames, alongside the new top-level function `tinypairs()`. Can be called with or without a formula. One benefit of the former is that it facilitates piping, e.g. - + ```r iris |> plt(Sepal.Length ~ Petal.Width | Species) ``` - + If no formula is provided, then the behaviour depends on the number of variables (columns) in the data frame. For example, a dataset with 3 or more - variables will yield a `pairs()`-style grid of all variable combinations. + variables will yield a `pairs()`-style grid of all variable combinations. Thanks to @mthulin for the suggestion and original implementation idea. (#613, #640 @zeileis @grantmcdermott) + - `tinyplot.matrix()`: for `matrix` objects, e.g. - + ```r plt(VADeaths, type = "b") ``` @@ -358,12 +354,13 @@ Theme fixes: The output largely mimics the base `matplot`/`matlines` equivalents, but with additional **tinyplot** functionality related to automatic legends, options for faceting, etc. (#649 @grantmcdermott) + - `tinyplot.ts()`: for `ts` time series, e.g. - + ```r plt(EuStockMarkets) ``` - + Produces a line plot by default, although users can override by passing an explicit `type` argument. Similarly, multivariate series are faceted by default, but users can also override to obtain, say, a single frame with @@ -372,6 +369,7 @@ Theme fixes: #### Other new features - New and updated top-level `tinyplot()`/`plt()` arguments: + - `cap = ` for adding a caption to your plots. Captions are drawn at the bottom of the plot and are best paired with dynamic themes (since separation from `sub` is guaranteed). Appearance is customizable via @@ -386,28 +384,31 @@ Theme fixes: names can be passed for convenience. Users can also pass a weights argument directly at the type-specific function level, but this must be a vector of correct length (no NSE). For example: - + ```r plt(y ~ x, data = dat, type = "lm", weights = w) # top-level, NSE plt(y ~ x, data = dat, type = type_lm(weights = dat$w)) # type-level, vector ``` - + In addition to NSE convenience, the top-level variant is preferred since it is correctly matched to the model frame construction with the formula method (e.g., so missing values are handled automatically). Thanks to @eleuven for the original suggestion, as well as various discussion participants for helping to frame the scope. (#639 @grantmcdermott) + - `labels = ` for passing labels to `type = "text"`. Like the new `weights` argument (above), the main benefit is the convenience of NSE, as well as the automatic handling of missing values and subsets as part of the model frame construction. For example, compare: - + ```r plt(y ~ x, data = dat, type = "text", labels = labs, subset = x < 10) plt(y ~ x, data = subset(dat, x < 10), type = type_text(labels = subset(dat, x < 10)$labs)) ``` + The `labels` arg is silently ignored for non-text types. (#639 @grantmcdermott) + - The `grid` argument (and `tpar("grid")`) now accepts character strings to control axis-specific grids at different resolutions. Uppercase letters (`"X"`, `"Y"`, `"XY"`) draw grid lines at the standard tick positions, while @@ -425,6 +426,7 @@ Theme fixes: limit and lets the data determine the other. - The string `"rev"` (or `"reverse"`) reverses the auto-computed axis range, without needing to know the data extent in advance. + - Type-specific updates: - `type_barplot()` gains an `offset` argument for shifting bar baselines away from zero. (#611, #615 @grantmcdermott @zeileis) @@ -481,14 +483,14 @@ Theme fixes: `"ridge"` types. (#635, #650 @grantmcdermott) - `tinyplot_add()` (`plt_add()`) now captures its arguments unevaluated, so arguments that rely on non-standard evaluation against `data` (e.g., - `plt_add(..., subset = <>)`) resolve correctly instead of erroring with + `plt_add(..., subset = <>)`) resolve correctly instead of erroring with "object not found". (#638 @grantmcdermott) - `plt(..., ann = FALSE)` correctly turns off title annotations now, fixing a regression that we missed from at least v0.6.0. Thanks to @bastistician for the report. (#641 @zeileis) - Fixed `bquote()` (and other unevaluated language) annotations such as `main`, `sub`, `cap`, `xlab`, and `ylab` being evaluated instead of coerced to - plotmath expressions, e.g. `plt(0, 0, main = bquote(foo == .(pi)))`. Thanks + plotmath expressions, e.g. `plt(0, 0, main = bquote(foo == .(pi)))`. Thanks (again) to @bastistician for the report. (#642 @grantmcdermott) - Line plots (`type = "l"`, and relatives like `"b"`/`"o"`) with a factor or character `x` variable now draw the category labels on the x-axis, matching @@ -529,7 +531,7 @@ Theme fixes: (#565 @grantmcdermott) - Several improvements/fixes to jittered plots and layering: - Jittered plots now support Date/POSIXt axes. Thanks to @wachtermh for the - bug report and @vincentarelbundock for the code contribution. (#327) + bug report and @vincentarelbundock for the code contribution. (#327) - `tinyplot_add(type = "jitter")` no longer errors when layered on top of boxplot, violin, or similar categorical plot types. (#560 @grantmcdermott) - Jitter layers added via `tinyplot_add()` now align correctly with grouped @@ -565,14 +567,14 @@ Theme fixes: will enable various internal enhancements, from improving the modularity and maintainability of the `tinyplot` codebase, to reducing memory overhead and performance (since we require fewer object copies). Looking ahead, we also - expect that it will make it easier to support new features and integration + expect that it will make it easier to support new features and integration with downstream packages. Most `tinyplot` users should be unaffected by these internal changes. However, users who have defined their own custom types will need to make some adjustments to match the new `settings` logic; details are provided in the updated `Types` vignette. (#473 @vincentarelbundock and @grantmcdermott) - The ancillary `fixed.pos` argument for dodged plots has been renamed to `fixed.dodge` to avoid ambiguity, especially when passed down from a top-level - `tinyplot(...)` call. (#528 @grantmcdermott) + `tinyplot(...)` call. (#528 @grantmcdermott) ### New features @@ -616,7 +618,6 @@ Theme fixes: - Custom axis titles work properly for one-sided (formula) bar plots. Thanks to @lbelzile for the report in #423. (#527 @grantmcdermott) - ### Documentation - Add a "recession bars" section to the `Tips & tricks` vignette. @@ -664,8 +665,8 @@ Theme fixes: `options()`. (#460 @zeileis) - Fixed several minor `tinylabel` bugs. (#468 @grantmcdermott) - `tinylabel(x, "%")` is more precise, preserving unique levels of `x` through - automatic decimal level determination. Thanks to @etiennebacher for the - bug report in #449. + automatic decimal level determination. Thanks to @etiennebacher for the + bug report in #449. - Numeric labellers now work on appropriate `x`/`y` variables, even if the plot type internally coerces it to factor (e.g., `"boxplot"`) - `type_text()` can now also deal with factor `x`/`y` variables by converting @@ -686,7 +687,7 @@ Theme fixes: - Move `altdoc` from `Suggests` to `Config/Needs/website`. Thanks to @etiennebacher for the suggestion and to @eddelbuettel for help with the CI implementation. -- Add a `devcontainer.json` file for remote testing. (#480 @grantmcdermott) +- Add a `devcontainer.json` file for remote testing. (#480 @grantmcdermott) ## v0.4.2 @@ -700,7 +701,7 @@ Theme fixes: ### Bug fixes - Fixed a long-standing issue whereby resizing the plot window would cause - secondary plot layers, e.g. from `plt_add()`, to become misaligned in + secondary plot layers, e.g. from `plt_add()`, to become misaligned in faceted plots (#313). This also resolves a related alignment + layering issue specific to the Positron IDE ([positron#7316](https://github.com/posit-dev/positron/issues/7316)). @@ -738,7 +739,7 @@ Theme fixes: - `"barplot"` / `type_barplot()` for bar plots. This closes out one of the last remaining canonical base plot types that we wanted to provide - a native `tinyplot` equivalent for. (#305 and #360 @zeileis and @grantmcdermott) + a native `tinyplot` equivalent for. (#305 and #360 @zeileis and @grantmcdermott) - `"violin"` / `type_violin()` for violin plots. (#354 @grantmcdermott) #### Other new features @@ -753,28 +754,30 @@ Theme fixes: - `xaxb`/`yaxb` control the manual break points of the axis tick marks. (#400 @grantmcdermott) - `xaxl`/`yaxl` apply a formatting function to change the appearance of the axis tick labels. (#363, #391 @grantmcdermott) - - These `x/yaxb` and `x/yaxl` arguments can be used in complementary fashion; - see the new (lower-level) `tinylabel` function documentation. For example: + These `x/yaxb` and `x/yaxl` arguments can be used in complementary fashion; + see the new (lower-level) `tinylabel` function documentation. For example: ```r tinyplot((0:10)/10, yaxb = c(.17, .33, .5, .67, .83), yaxl = "%") ``` - The `x/ymin` and `x/ymax` arguments can now be specified directly via the `tinyplot.formula()` method thanks to better NSE processing. For example, instead of having to write + ```r with(dat, tinyplot(x = x, y = y, by = by ymin = lwr, ymax = upr)) ``` + users can now do + ```r tinyplot(y ~ x | by, dat, ymin = lwr, ymax = upr) ``` - + Underneath the hood, this works by processing these NSE arguments as part of formula `model.frame()` and reference against the provided dataset. We plan to extend the same logic to other top-level formula arguments such as `weights` and `subset` in a future version of tinyplot. - + ### Bug fixes: - The `tinyplot(..., cex = )` argument should be respected when using @@ -882,32 +885,31 @@ _(Primary PR and author: #222 @vincentarelbundock)_ #### Support for additional plot types - - Visualizations: - - - `type_spineplot()` (shortcut: `"spineplot"`) spine plots and +- Visualizations: + + - `type_spineplot()` (shortcut: `"spineplot"`) spine plots and spinograms. These are modified versions of a histogram or mosaic plot, and are particularly useful for visualizing factor variables. (#233 @zeileis with contributions from @grantmcdermott) - - `type_qq()` (shortcut: "qq") for quantile-quantile plots. (#251 + - `type_qq()` (shortcut: "qq") for quantile-quantile plots. (#251 @vincentarelbundock) - - `type_ridge()` (shortcut: `"ridge"`) for ridge plots aka Joy plots. + - `type_ridge()` (shortcut: `"ridge"`) for ridge plots aka Joy plots. (#252 @vincentarelbundock, @zeileis, and @grantmcdermott) - - `type_rug()` (shortcut: `"rug"`) adds a rug to an existing plot. (#276 + - `type_rug()` (shortcut: `"rug"`) adds a rug to an existing plot. (#276 @grantmcdermott) - - `type_text()` (shortcut: `"text"`) adds text annotations. (@vincentarelbundock) - - - Models: - - `type_glm()` (shortcut: `"glm"`) (@vincentarelbundock) - - `type_lm()` (shortcut: `"lm"`) (@vincentarelbundock) - - `type_loess()` (shortcut: `"loess"`) (@vincentarelbundock) - - `type_spline()` (shortcut: `"spline"`) (#241 @grantmcdermott) - - - Functions: - - `type_abline()`: line(s) with intercept and slope (#249 @vincentarelbundock) - - `type_hline()`: horizontal line(s) (#249 @vincentarelbundock) - - `type_vline()`: vertical line(s) (#249 @vincentarelbundock) - - `type_function()`: arbitrary function. (#250 @vincentarelbundock) - - `type_summary()`: summarize values of `y` along unique values of `x` (#274 + - `type_text()` (shortcut: `"text"`) adds text annotations. (@vincentarelbundock) + +- Models: + - `type_glm()` (shortcut: `"glm"`) (@vincentarelbundock) + - `type_lm()` (shortcut: `"lm"`) (@vincentarelbundock) + - `type_loess()` (shortcut: `"loess"`) (@vincentarelbundock) + - `type_spline()` (shortcut: `"spline"`) (#241 @grantmcdermott) +- Functions: + - `type_abline()`: line(s) with intercept and slope (#249 @vincentarelbundock) + - `type_hline()`: horizontal line(s) (#249 @vincentarelbundock) + - `type_vline()`: vertical line(s) (#249 @vincentarelbundock) + - `type_function()`: arbitrary function. (#250 @vincentarelbundock) + - `type_summary()`: summarize values of `y` along unique values of `x` (#274 @grantmcdermott) #### Themes @@ -932,32 +934,30 @@ _(Primary PR and authors: #258 @vincentarelbundock and @grantmcdermott)_ #### Other new features - New `tinyplot()` arguments: - - `flip ` allows for easily flipping (swapping) the orientation - of the x and y axes. This should work regardless of plot type, e.g. - `tinyplot(~Sepal.Length | Species, data = iris, type = "density", flip = TRUE)`. - (#216 @grantmcdermott) + - `flip ` allows for easily flipping (swapping) the orientation + of the x and y axes. This should work regardless of plot type, e.g. + `tinyplot(~Sepal.Length | Species, data = iris, type = "density", flip = TRUE)`. + (#216 @grantmcdermott) - `draw = ` allows users to pass arbitrary drawing functions that - are evaluated as-is, before the main plotting elements. A core use case is - drawing common annotations across every facet of a faceted plot, e.g. text or - threshold lines. (#245 @grantmcdermott) + are evaluated as-is, before the main plotting elements. A core use case is + drawing common annotations across every facet of a faceted plot, e.g. text or + threshold lines. (#245 @grantmcdermott) - `facet.args` gains a `free = ` sub-argument for independently - scaling the axes limits of individual facets. (#253 @grantmcdermott) - + scaling the axes limits of individual facets. (#253 @grantmcdermott) - `tpar()` gains additional `grid.col`, `grid.lty`, and `grid.lwd` arguments for fine-grained control over the appearance of the default panel grid when `tinyplot(..., grid = TRUE)` is called. (#237 @grantmcdermott) - - The new `tinyplot_add()` (alias: `plt_add()`) convenience function allows -easy layering of plots without having to specify repeat arguments. (#246 -@vincentarelbundock) + easy layering of plots without having to specify repeat arguments. (#246 + @vincentarelbundock) ### Breaking changes - There are a few breaking changes to grouped density plots. - The joint smoothing bandwidth is now computed using an observation-weighted - mean (as opposed to a simple mean). Users can customize this joint bandwidth + mean (as opposed to a simple mean). Users can customize this joint bandwidth by invoking the new `type_density(joint.bw =