Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 83 additions & 27 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ where the formatting is also better._
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 you can still select into the
old behaviour by passing the (new) `xlevels = "asis"` argument as an
old behaviour by passing the (new) `xord = "asis"` argument as an
explicit override; see "Other new features" below. (#683 @grantmcdermott)

### New features
Expand All @@ -32,6 +32,14 @@ where the formatting is also better._
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
explicit `ylim` to override. (#677 @grantmcdermott)
- While not strictly a new plot type, `type_area()` gains a new `stack` argument
for drawing _stacked_ area plots, where each layer represents a discrete `by`
category group. This functionality is further enhanced by two (also new)
sister arguments. First, `byord` enables on-the-fly (re-)ordering of the
stacked `by` layers, via convenience keywords or custom functions (e.g.,
`byord = "end"` ranks groups according to their largest final value). Second,
a `FUN` argument permits stacking of multi-observation data by collapsing
repeated `y` values. (#688 @grantmcdermott)

#### Facet improvements

Expand Down Expand Up @@ -66,28 +74,63 @@ Note that each of these `facet.args` arguments is paired with an equivalent
to set this behaviour globally. This also means that they can be set as part of
a (custom) theme, e.g. `tinytheme("clean", facet.axes = "outer")`.

#### Ordering and labelling categorical variables

This release brings several enhancements for working with _categorical_
variables, i.e. where `x`, `y`, or `by` are characters or factors with discrete
levels. This includes improvements to existing arguments, as well as the
provision of some new arguments that enable finer control over level ordering
and convenient label formatting.

- `xlevels`, `ylevels`: these (type-level) arguments permit on-the-fly
reordering of a categorical variable via _literal_ specification, either a
character vector of level names (e.g., `c("C", "B", "A")`), or a numeric
vector of level indices (e.g., `3:1`). While this argument is not new---having
been supported by `type_barplot` and several other types for a while---we now
extend `xlevels` support to `type_points()`, `type_lines()`,
`type_errorbar()`, and `type_pointrange()`. (#683, #694 @grantmcdermott)
- `xord`, `yord`: these are new (type-level) arguments that provide an alternate
ordering interface to `x/ylevels`. Specifically, while `x/ylevels` require a
literal ordering, `x/yord` _computes_ the order on the fly, according to
(type-appropriate) convenience keywords or a custom ranking function. For
example, `"desc(ending)"`/`"asc(ending)"` orders by value, while `"asis"`
ignores factor levels and just takes the order of appearance in the data as
given. Among other things, this makes it possible to sort barplots by height
(e.g., `type_barplot(xord = "desc")`), or ridges by their spread (e.g.,
`type_ridge(yord = "minvar")`) without relevelling the underlying factor by
hand. (#683, #694 @grantmcdermott)
- (Note: users should only supply one of the preceding sets of arguments. If
both `x/ylevels` and `x/yord` are provided, the former takes precedence as
the more explicit.)
- `tinylabel()` gains a dictionary form, i.e., a *named* character vector or
list that maps existing labels to new ones _a la_
`c(old1 = "new1", old2 = "new2")`. Partial mapping is fine since the lookup is
by value rather than by position, so that some levels can be left unnamed.
Importantly, this behaviour extends to the rest of **tinyplot**'s
(re)labelling machinery---including `x/yaxl`, `type_text()`, and any function
with a `labeller` argument---since everything is routed through `tinylabel()`.
(#690 @grantmcdermott)
- The type-level `x/yaxlabels` arguments of `type_spineplot()` and
`type_barplot()` are deprecated in favour of the top-level `x/yaxl` arguments.
The type-level arguments predated their top-level cousins, which now offer the
same functionality via a consistent interface across _all_ types. The old
arguments still work (with a warning) for now. But we will be formally
removing them in a future release and, going forwards, encourage users to move
over to `xaxl` and `yaxl` as the idiomatic **tinyplot** way to relabel
and format axis ticks. (#692 @grantmcdermott)

Beyond convenience, these improvements to categorical variable handling also
provide the scaffolding to eliminate some niggling inconsistencies; for example,
related to plot layering. See "Bug fixes" below.

#### Other new features

- `type_area()` gains a `stack` argument for stacked area plots. A sister
`byord` argument enables convenient, on-the-fly (re-)ordering of stacking
layers through convenience keywords or custom functions (e.g.,
`byord = "end"` ranks groups according to their largest final value, while
`byord = "minvar"` puts the lowest variance group on the baseline, and
`byord = "rev"` simply reverses the existing level order). Custom
functions may additionally name an `x` argument to receive the group's `x`
values, as needed by any statistic that depends on their spacing.
Similarly, a new `FUN` argument permits stacking of multi-observation data by
collapsing repeated `y` values. (#688 @grantmcdermott)
- `type_points()`, `type_lines()`, `type_errorbar()`, and `type_pointrange()`
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)
- `type_density()` gains an `echo.bw` argument for reporting the smoothing
bandwidth and the number of observations behind it, neither of which is
visible from the curve itself. Destinations are `"sub"`, `"cap"`, and
`"cat"` (console), in any combination; a destination the user has already
labelled is left alone. Shared bandwidths are reported once and named as
joint, individual bandwidths per group. (#287 @haomeng797-ship-it)
- 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
Expand All @@ -97,12 +140,6 @@ a (custom) theme, e.g. `tinytheme("clean", facet.axes = "outer")`.
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)
- `type_density()` gains an `echo.bw` argument for reporting the smoothing
bandwidth and the number of observations behind it, neither of which is
visible from the curve itself. Destinations are `"sub"`, `"cap"`, and
`"cat"` (console), in any combination; a destination the user has already
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
long list of category names on the y-axis without also shrinking the x-axis.
Expand All @@ -117,6 +154,25 @@ a (custom) theme, e.g. `tinytheme("clean", facet.axes = "outer")`.

### Bug fixes

- Layers added with `tinyplot_add()` now align correctly when the base plot type
coerces a numeric `x` variable to a factor, as `type_barplot()` and
`type_violin()` do. The base layer's categories are the coerced *labels*,
while the added layer still carried the raw values, so it was drawn at those
coordinates instead of at the category positions---often well outside the
plotting region. (#691 @grantmcdermott)
- Axis labellers no longer blow up the decimal precision when the breaks are
symmetric about zero, as they are for a centered barplot. `tinyplot(...,
center = TRUE, yaxl = "percent")` labelled its axis `80.00000%` rather than
`80%`. (#689 @grantmcdermott)
- The top-level `xaxl` / `yaxl` arguments now work for `type_spineplot()` and
`type_ridge()`. Both types draw their own axes, and so never reached the
standard path where those arguments are applied, meaning they were silently
ignored. (#694 @grantmcdermott)
- `xlevels` / `ylevels` no longer drop data silently. Naming a strict subset of
a variable's levels sent every other level to `NA`, quietly removing those
observations from the plot; this now warns. Supplying a value that matches no
level at all is now an error, rather than surfacing later as an unrelated
complaint about zero-length ranges. (#688, #694 @grantmcdermott)
- `type_area()` now labels a categorical `x` axis with its factor levels,
rather than falling back to the underlying integer positions.
(#688 @grantmcdermott)
Expand Down
19 changes: 17 additions & 2 deletions R/align_layer.R
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,23 @@ align_layer = function(settings) {

# Only adjust if original layer has named xlabs
if (!is.null(names(xlabs_orig))) {
if (is.factor(settings$datapoints[["x"]])) {
# Case 1: relevel a factor (e.g., ribbon added to errorbars)
# The atomic branch of this condition covers a base layer that coerced a
# numeric/character x to a factor itself (bars, ridges): its categories are
# the *labels*, while the added layer still carries the raw values. Those
# values are releveled below just like a factor would be, and the resulting
# integer codes are the positions the base layer drew at.
#
# Both extra tests are load-bearing. Requiring the layer to have no named
# xlabs of its own leaves Case 2 owning layers that already converted --
# otherwise a base whose categories are literally "1", "2", "3" would have
# the layer's integer *positions* misread as labels. Requiring every value
# to match leaves a partial overlap alone, rather than silently turning the
# unmatched rows into NA and dropping them from the plot.
if (is.factor(settings$datapoints[["x"]]) ||
(is.null(names(xlabs_layer)) &&
all(as.character(settings$datapoints[["x"]]) %in% names(xlabs_orig)))) {
# Case 1: relevel a factor (e.g., ribbon added to errorbars), or an
# atomic x whose values name the original layer's categories
settings$datapoints[["x"]] = tryCatch(
factor(settings$datapoints[["x"]], levels = names(xlabs_orig)),
error = function(e) {
Expand Down
29 changes: 24 additions & 5 deletions R/assertions.R
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,23 @@ is_string1 = function(x) {
isTRUE(check_string(x)) && !is.na(x)
}

# label formatter passed on to tinylabel(): a function, or one of its
# convenience strings (e.g. "percent"). With `list.ok`, several of them -- as a
# list or character vector -- are allowed too, e.g. one per facet variable.
# label formatter passed on to tinylabel(): a function, one of its convenience
# strings (e.g. "percent"), or a dictionary. With `list.ok`, several of them --
# as a list or character vector -- are allowed too, e.g. one per facet variable.
#
# A dictionary is only recognised *inside* that list, never as `x` itself: at
# the top level a named vector is read as a per-variable mapping instead, which
# is what makes `list(Species = c(setosa = "SET"))` mean something different
# from a bare `c(setosa = "SET")`. match_facet_vars() enforces the top-level
# reading; this only has to let the nested one through.
assert_labeller = function(x, name = as.character(substitute(x)), list.ok = FALSE) {
if (is.null(x) || is_labeller(x)) return(invisible(TRUE))
if (isTRUE(list.ok) && (is.list(x) || is.character(x)) && length(x) >= 1L) {
if (all(vapply(x, is_labeller, logical(1L)))) return(invisible(TRUE))
ok = vapply(x, function(xi) is_labeller(xi) || is_dict(xi), logical(1L))
if (all(ok)) return(invisible(TRUE))
}
msg = if (isTRUE(list.ok)) {
"`%s` must be a function or a `tinylabel()` convenience string, or a list of them (one per facet variable)."
"`%s` must be a function, a `tinylabel()` convenience string, or a dictionary of labels -- or a list of them, one per facet variable."
} else {
"`%s` must be a function, or a `tinylabel()` convenience string."
}
Expand All @@ -123,6 +130,18 @@ is_labeller = function(x) {
is.function(x) || (is.character(x) && length(x) == 1L && !is.na(x))
}

# A tinylabel() dictionary: a *named* character vector, or a named list that
# flattens to one, mapping existing labels to their replacements. Deliberately
# mirrors what tinylabel() itself dispatches on (see R/tinylabel.R), so that
# what passes validation here is exactly what it can consume. Note the overlap
# with is_labeller() at length 1, where a one-entry dictionary and a
# convenience string cannot be told apart -- and need not be, since both are
# accepted either way.
is_dict = function(x) {
if (is.list(x) && !is.null(names(x))) x = unlist(x)
is.character(x) && !is.null(names(x))
}

assert_length = function(x, len = 1, null.ok = FALSE, name = as.character(substitute(x))) {
if (is.null(x) && isTRUE(null.ok)) {
return(invisible(TRUE))
Expand Down
13 changes: 9 additions & 4 deletions R/legend.R
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,9 @@ prepare_legend = function(settings) {
#' @param legend_args Additional legend arguments
#' @param by_dep The (deparsed) "by" grouping variable name
#' @param lgnd_labs The legend labels
#' @param labeller Character or function for formatting labels
#' @param labeller Function, character keyword, or dictionary (named vector or
#' list) for formatting or relabelling the labels. See [`tinylabel`] for the
#' accepted forms.
#' @param type Plot type
#' @param pch Plotting character(s)
#' @param lty Line type(s)
Expand Down Expand Up @@ -770,7 +772,9 @@ reverse_legend_keys = function(legend_args, n) {
#' @param legend_args Additional legend arguments
#' @param by_dep The (deparsed) "by" grouping variable name
#' @param lgnd_labs The legend labels
#' @param labeller Character or function for formatting labels
#' @param labeller Function, character keyword, or dictionary (named vector or
#' list) for formatting or relabelling the labels. See [`tinylabel`] for the
#' accepted forms.
#' @param type Plot type
#' @param pch Plotting character(s)
#' @param lty Line type(s)
Expand Down Expand Up @@ -879,8 +883,9 @@ build_legend_env = function(
#' \code{\link[graphics]{legend}}.
#' @param by_dep The (deparsed) "by" grouping variable name.
#' @param lgnd_labs The labels passed to `legend(legend = ...)`.
#' @param labeller Character or function for formatting the labels (`lgnd_labs`).
#' Passed down to [`tinylabel`].
#' @param labeller Function, character keyword, or dictionary (named vector or
#' list) for formatting or relabelling the labels (`lgnd_labs`). See
#' [`tinylabel`] for the accepted forms. Passed down to [`tinylabel`].
#' @param type Plotting type(s), passed down from [tinyplot].
#' @param pch Plotting character(s), passed down from [tinyplot].
#' @param lty Plotting linetype(s), passed down from [tinyplot].
Expand Down
38 changes: 38 additions & 0 deletions R/ordering_args.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
## Guards shared by the `*levels` / `*ord` argument pair. Both members control
## the same thing -- the order of a categorical variable's levels -- so these
## are named for that concept rather than for either argument.
##
## Note the pair is resolved by precedence rather than by a guard: `*levels`
## wins and `*ord` is skipped at the call site when both are given. That keeps
## the types free of supplied-vs-default bookkeeping, which would otherwise be
## needed because `type_errorbar()` and `type_pointrange()` default `xord` to
## "asis".

## Warn when `*levels` / `*ord` were supplied for an axis that cannot be
## reordered. Types that coerce their categorical axis to a factor (barplot,
## ridge) never reach this -- there the arguments always apply. For the point
## and line family a numeric `x` is plotted at its own values, so the request
## is silently dropped, which is the failure mode worth surfacing.
##
## `supplied` is passed by the caller rather than inferred, because these
## arguments reach here through a closure: `type_errorbar()` and
## `type_pointrange()` default `xord` to "asis", and warning about a default
## the user never typed would fire on every numeric-x coefficient plot.
warn_ignored_ordering = function(v, xlevels, ord, supplied = TRUE) {
nms = c(deparse(substitute(xlevels)), deparse(substitute(ord)))
if (is.factor(v) || !isTRUE(supplied)) {
return(invisible(NULL))
}
given = c(if (!is.null(xlevels)) nms[1L], if (!is.null(ord)) nms[2L])
if (length(given) == 0L) {
return(invisible(NULL))
}
warning(
sprintf(
"ignoring '%s': only categorical (factor or character) variables can be reordered.",
paste(given, collapse = "' and '")
),
call. = FALSE
)
invisible(NULL)
}
Loading