Skip to content

Commit ced4205

Browse files
Control single- vs multi-group palettes via col.default (#614)
* Single-group colors via `col.default` * update tests * use "ggplot" palette for (built-in) ggplot2-inspired themes * updates * news * single-group tests * fix old register test * pr link in news * fix legacy 'bug' for single group displays without theme * histogram gotcha * Implement @zeileis's suggestions * regenerate snapshots * news * fix old snapshot * spineplot gotcha * consolidate code
1 parent 11e30a0 commit ced4205

66 files changed

Lines changed: 5148 additions & 561 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

NEWS.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ margin spacing and related plot elements to reduce whitespace and improve the
4646
overall plot aesthetic. The `?tinytheme` help file and online
4747
[Themes](https://grantmcdermott.com/tinyplot/vignettes/themes.html) vignette
4848
cover the new features in detail. But see below for the highlights.
49-
(#549, #591, #595, #606 @grantmcdermott, @vincentarelbundock)
49+
(#549, #591, #595, #606, #614 @grantmcdermott, @vincentarelbundock, @zeileis)
5050

5151
New theme features:
5252

@@ -80,6 +80,39 @@ New theme features:
8080
`tinytheme(<theme>)` or `tinyplot(..., theme = <theme>)`. Companion functions
8181
`tinytheme_list()` and `tinytheme_unregister()` further support this
8282
functionality. (#608 @grantmcdermott)
83+
- We adopt a new logic for selecting the default colour for single-group
84+
(i.e. plots without a `by` grouping) versus multi-group displays. The upshot
85+
is that, for many themes, single-group displays will simply default to
86+
"black", whereas multi-group displays will drop "black" and only present
87+
colour palettes. This behaviour is operationalized through the new
88+
`col.default` parameter, settable via `tpar()` or within a theme.
89+
(#614 @grantmcdermott @zeileis)
90+
- Themes whose palette already leads with a non-black colour (e.g.
91+
`"clean(2)"`, `"dark"`, `"nber"`, `"web"`) consistently use that colour for
92+
single-group displays too.
93+
- The ggplot2-inspired themes (`"bw"`, `"classic"`, `"linedraw"`, and
94+
`"minimal"`) continue to use black for single-group display. But they now
95+
drop the leading black from their _grouped_ palettes. (Note that these
96+
themes also use the default `"ggplot2"` palette now, so grouped plots start
97+
at the familiar salmon hue.)
98+
- Similarly, the `"ipsum2"` and `"broadsheet"` themes use the
99+
Okabe-Ito palette less its leading black, so grouped plots start at orange.
100+
The `"float"` and `"void"` themes also default to black for single-group
101+
displays.
102+
- Relatedly, the _fill_ of single-group `"boxplot"`, `"violin"`, `"barplot"`,
103+
and `"histogram"` displays is now unified across these four types, so that a
104+
single-group plot looks the same regardless of which one you reach for.
105+
(#614 @grantmcdermott @zeileis)
106+
- When the resolved default colour is _chromatic_ (e.g. the blue of `"clean"`
107+
or the teal of `"dark"`), the fill is a lighter-but-opaque tint of it;
108+
following the same sequential HCL ramp that `"ridge"` type plots have been
109+
using for a while.
110+
- When the default is _achromatic_ (black, as in the plain default and the
111+
`"bw"`/`"classic"`/`"ipsum"` themes), all four types share a neutral
112+
`"lightgray"` fill, matching base R's `hist()` and `boxplot()` defaults.
113+
Note that this does imply a change for `"barplot"` types, which previously
114+
used a slightly darker `"grey"` (to match base R's `barplot()`), but we
115+
decided internal consistency was the more important feature to prioritize.
83116

84117
Theme fixes:
85118

R/by_aesthetics.R

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,44 @@ match_palette_name = function(name, candidates) {
122122
charmatch(normalize(name), normalize(candidates))
123123
}
124124

125+
## Resolve a palette spec to its full concrete colour vector (or NULL if it
126+
## can't be resolved to a discrete set, e.g. a continuous hcl palette). Used to
127+
## support relative `col.default` indices.
128+
resolve_palette_to_colors = function(palette) {
129+
if (is.null(palette)) return(NULL)
130+
if (is.character(palette) && length(palette) > 1) return(unname(palette))
131+
if (is.character(palette) && length(palette) == 1) {
132+
discrete_pals = palette.pals()
133+
idx = match_palette_name(palette, discrete_pals)
134+
if (!is.na(idx) && idx >= 1L) {
135+
return(unname(palette.colors(palette = discrete_pals[idx])))
136+
}
137+
}
138+
NULL
139+
}
140+
141+
## Resolve a (possibly relative) `col.default` against the qualitative palette.
142+
## `col_default` may be NULL, a character colour, or a numeric index into the
143+
## palette. A negative index additionally *drops* that colour from the palette
144+
## returned for grouped displays. So `col.default = -1` with an "Okabe-Ito"
145+
## palette uses black (the leading colour) for single-group plots and an
146+
## Okabe-Ito-minus-black palette for grouped plots, avoiding the need to keep a
147+
## separate "no-black" palette copy around (#598, #614). Returns a list with the
148+
## resolved single-group `col_default` and the (possibly trimmed) `palette`.
149+
resolve_col_default = function(col_default, palette_spec) {
150+
if (!is.numeric(col_default)) {
151+
return(list(col_default = col_default, palette = palette_spec))
152+
}
153+
full = resolve_palette_to_colors(palette_spec)
154+
if (is.null(full)) {
155+
return(list(col_default = NULL, palette = palette_spec))
156+
}
157+
i = as.integer(col_default)
158+
out_col = full[abs(i)]
159+
if (i < 0L) palette_spec = full[-abs(i)]
160+
list(col_default = out_col, palette = palette_spec)
161+
}
162+
125163
## Handle direct color input via `col` arg. Returns colors or NULL if not applicable.
126164
resolve_manual_colors = function(col, ngrps, gradient, ordered, alpha, adjustcolor) {
127165
if (is.null(col) || !is.atomic(col) || !is.vector(col)) {
@@ -296,16 +334,32 @@ by_col = function(col, palette, alpha, by_ordered, by_continuous, ngrps, adjustc
296334

297335
if (is_by_keyword(col)) col = NULL
298336

337+
pal_theme = if (ordered || gradient) {
338+
get_tpar("palette.sequential", default = NULL)
339+
} else {
340+
get_tpar("palette.qualitative", default = NULL)
341+
}
342+
343+
# Resolve a (possibly relative) col.default against the qualitative palette.
344+
# A relative (negative) index also drops the chosen colour from the grouped
345+
# palette, so e.g. `col.default = -1` uses the palette's leading colour for
346+
# single-group displays and the palette-minus-that-colour for grouped ones
347+
# (see resolve_col_default). col.default only applies to qualitative displays.
348+
if (!ordered && !gradient) {
349+
cd = resolve_col_default(get_tpar("col.default", default = NULL), pal_theme)
350+
pal_theme = cd[["palette"]]
351+
# Single-group default colour (theme-settable via tpar). When NULL, defer to
352+
# the usual palette resolution below (first qualitative colour, or palette()[1]).
353+
if (is.null(col) && ngrps == 1L) {
354+
col = cd[["col_default"]]
355+
}
356+
}
357+
299358
cols = resolve_manual_colors(col, ngrps, gradient, ordered, alpha, adjustcolor)
300359
if (!is.null(cols)) {
301360
return(cols)
302361
}
303362

304-
pal_theme = if (ordered || gradient) {
305-
get_tpar("palette.sequential", default = NULL)
306-
} else {
307-
get_tpar("palette.qualitative", default = NULL)
308-
}
309363
cols = resolve_palette_colors(
310364
palette = palette,
311365
theme_palette = pal_theme,
@@ -330,10 +384,15 @@ by_bg = function(bg, fill, col, palette, alpha, by_ordered, by_continuous, ngrps
330384
ordered = if (is.null(by_ordered)) FALSE else by_ordered
331385
gradient = if (is.null(by_continuous)) FALSE else by_continuous
332386
pal_theme = if (ordered || gradient) {
333-
get_tpar("palette.sequential", default = NULL)
387+
get_tpar("palette.sequential", default = NULL)
334388
} else {
335389
get_tpar("palette.qualitative", default = NULL)
336390
}
391+
# Mirror by_col(): a relative col.default trims the grouped palette, so
392+
# grouped fills stay in step with grouped line colours.
393+
if (!ordered && !gradient) {
394+
pal_theme = resolve_col_default(get_tpar("col.default", default = NULL), pal_theme)[["palette"]]
395+
}
337396
bg = resolve_palette_colors(
338397
palette = palette,
339398
theme_palette = pal_theme,
@@ -352,6 +411,26 @@ by_bg = function(bg, fill, col, palette, alpha, by_ordered, by_continuous, ngrps
352411
} else if (!is.null(col)) {
353412
bg = adjustcolor(col, ribbon.alpha)
354413
}
414+
} else if (ngrps == 1L && is.null(bg) && type %in% c("boxplot", "violin", "barplot", "histogram")) {
415+
# Single-group fill tracks the theme's *default* colour (col.default ->
416+
# palette[1] -> black) so that themed box/violin/bar/histogram plots match
417+
# their own multi-group counterparts. We resolve this default independently
418+
# of `col` so that a user-supplied outline colour (e.g. `col = "white"`)
419+
# doesn't bleed into the fill.
420+
fill_base = by_col(
421+
col = NULL, palette = palette, alpha = 1, by_ordered = by_ordered,
422+
by_continuous = by_continuous, ngrps = 1L, adjustcolor = adjustcolor
423+
)
424+
# For a *chromatic* default the fill is a lighter-but-opaque tint of that
425+
# colour (via seq_palette, the same HCL ramp used for ridge fills and legend
426+
# swatches), so it reads cleanly over grid lines unlike alpha blending. For
427+
# an *achromatic* default (typically black, e.g. the "bw"/"classic"/"ipsum"
428+
# themes and the plain default) seq_palette's light endpoint can't reach the
429+
# neutral "lightgray" used by the no-theme path and base R's hist()/boxplot()
430+
# -- so we use that literal directly, keeping all black-default single-group
431+
# fills consistent regardless of whether a theme palette happens to be set.
432+
achromatic = is_achromatic(fill_base)
433+
bg = if (achromatic) "lightgray" else seq_palette(fill_base, n = 3)[3]
355434
}
356435

357436
bg

R/tinytheme.R

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -418,11 +418,12 @@ theme_classic = modifyList(theme_dynamic, list(
418418
bty = "l",
419419
cex.axis = 0.8,
420420
cex.cap = 0.8,
421+
col.default = -1L, # black single-group; drop it from the grouped palette
421422
facet.bg = NULL,
422423
font.main = 1,
423424
gap.axis = 0.1,
424425
gap.lab = 0.4,
425-
palette.qualitative = "Okabe-Ito"
426+
palette.qualitative = "ggplot2"
426427
))
427428

428429
# derivatives of "clean"
@@ -439,6 +440,7 @@ theme_clean2 = modifyList(theme_clean, list(
439440
theme_bw = modifyList(theme_clean, list(
440441
tinytheme = "bw",
441442
cex.axis = 0.8,
443+
col.default = -1L, # black single-group; drop it from the grouped palette
442444
font.main = 1,
443445
gap.axis = 0.1,
444446
gap.lab = 0.4,
@@ -447,7 +449,7 @@ theme_bw = modifyList(theme_clean, list(
447449
grid.lwd = 0.5,
448450
lwd = 0.5,
449451
lwd.axis = 0.5,
450-
palette.qualitative = "Okabe-Ito"
452+
palette.qualitative = "ggplot2"
451453
))
452454

453455
theme_linedraw = modifyList(theme_bw, list(
@@ -477,6 +479,7 @@ theme_ipsum = modifyList(theme_minimal, list(
477479
adj.xlab = 1,
478480
adj.ylab = 1,
479481
cex.lab = 0.8,
482+
col.default = "black", # bespoke palette doesn't lead with black; pin it
480483
font.main = 2,
481484
font.sub = 1,
482485
font.cap = 3,
@@ -493,12 +496,15 @@ theme_ipsum2 = modifyList(theme_minimal, list(
493496
bty = "n",
494497
font.sub = 3,
495498
adj.ylab = 1,
496-
adj.xlab = 1
499+
adj.xlab = 1,
500+
col.default = -1L, # black single-group; drop it from the grouped palette
501+
palette.qualitative = "Okabe-Ito" # keep Okabe-Ito, not the ggplot2 palette
497502
))
498503

499504
theme_dark = modifyList(theme_minimal, list(
500505
tinytheme = "dark",
501506
bg = "#1A1A1A",
507+
col.default = NULL, # no fixed default: single-group uses palette[1] (Set 2)
502508
fg = "#BBBBBB",
503509
# col = "white",
504510
col.xaxs = "#BBBBBB",
@@ -519,11 +525,13 @@ theme_dark = modifyList(theme_minimal, list(
519525

520526
theme_ridge = modifyList(theme_clean, list(
521527
tinytheme = "ridge",
528+
col.default = "black", # keep black ridgelines; Zissou is for gradient fills
522529
palette.qualitative = "Zissou 1",
523530
grid = FALSE
524531
))
525532
theme_ridge2 = modifyList(theme_clean2, list(
526533
tinytheme = "ridge2",
534+
col.default = "black", # keep black ridgelines; Zissou is for gradient fills
527535
palette.qualitative = "Zissou 1",
528536
grid = FALSE
529537
))
@@ -539,6 +547,7 @@ theme_socviz = modifyList(theme_minimal, list(
539547
cex.lab = 1,
540548
cex.main = 1.4,
541549
cex.sub = 1.05,
550+
col.default = "black", # bespoke palette doesn't lead with black; pin it
542551
col.xaxs = "gray10",
543552
col.yaxs = "gray10",
544553
facet.bg = NULL,
@@ -565,6 +574,7 @@ theme_broadsheet = modifyList(theme_dynamic, list(
565574
bty = "n",
566575
cex.cap = 0.8,
567576
col.cap = "gray40",
577+
col.default = -1L, # black single-group; drop it from the grouped palette
568578
col.sub = "gray40",
569579
font.main = 2,
570580
gap.axis = 0.1,
@@ -584,6 +594,7 @@ theme_broadsheet = modifyList(theme_dynamic, list(
584594
theme_nber = modifyList(theme_broadsheet, list(
585595
tinytheme = "nber",
586596
bg = "#F2F7FB",
597+
col.default = NULL, # override broadsheet's black: nber palette leads with blue
587598
cex.cap = 1,
588599
cex.main = 1.4,
589600
cex.sub = 1,
@@ -641,6 +652,7 @@ theme_tufte = modifyList(theme_dynamic, list(
641652

642653
theme_float = modifyList(theme_tufte, list(
643654
tinytheme = "float",
655+
col.default = "black",
644656
gap.axis = 0,
645657
gap.lab = 0.7,
646658
lab = c(5, 5, 7),
@@ -650,6 +662,7 @@ theme_float = modifyList(theme_tufte, list(
650662

651663
theme_void = modifyList(theme_dynamic, list(
652664
tinytheme = "void",
665+
col.default = "black",
653666
facet.bg = NULL,
654667
facet.border = NA,
655668
font.main = 1,

R/tpar.R

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
#' * `cairo`: Logical indicating whether \code{\link[grDevices]{cairo_pdf}} should be used when writing plots to PDF. If `FALSE`, then \code{\link[grDevices]{pdf}} will be used instead, with implications for embedding (non-standard) fonts. Only used if `tinyplot(..., file = "<filename>.pdf")` is called. Defaults to the value of `capabilities("cairo")`.
5656
#' * `cex.cap`: Numeric expansion factor for the plot caption text. Defaults to `1` for the default, basic, and dynamic themes, and `0.8` for clean/classic and their descendants.
5757
#' * `col.cap`: Character specifying the colour of the plot caption. Defaults to `"black"`.
58+
#' * `col.default`: Default colour for single-group displays (i.e. plots without a `by` grouping). Can be `NULL`, a length-1 character colour, or a length-1 numeric index into `palette.qualitative`. Defaults to `NULL`, in which case the first colour of the active qualitative palette is used (or base `palette()[1]`, typically black, if no theme is set). A character value sets the single-group colour independently of the multi-group palette. A numeric value `i` selects the `i`th colour of `palette.qualitative` as the single-group default; a *negative* index additionally drops that colour from the palette used for grouped plots. For example, `col.default = -1` paired with `palette.qualitative = "Okabe-Ito"` uses black (the leading palette colour) for single-group plots and an Okabe-Ito-minus-black palette for grouped plots, avoiding the need to maintain a separate "no-black" palette.
5859
#' * `font.cap`: Integer specifying the font face for the plot caption (`1` = plain, `2` = bold, `3` = italic, `4` = bold italic). Defaults to `1`.
5960
#' * `line.cap`: Numeric specifying the margin line on which to draw the caption. If `NULL` (default), computed automatically based on the available bottom margin.
6061
#' * `dynmar`: Logical indicating whether `tinyplot` should attempt dynamic adjustment of margins to reduce whitespace and/or account for spacing of text elements (e.g., long horizontal y-axis labels). Note that this parameter is tightly coupled to internal `tinythemes()` logic and should _not_ be adjusted manually unless you really know what you are doing or don't mind risking unintended consequences to your plot.
@@ -234,6 +235,7 @@ known_tpar = c(
234235
"cex.xlab",
235236
"cex.ylab",
236237
"col.cap",
238+
"col.default",
237239
"col.xaxs",
238240
"col.yaxs",
239241
"cairo",
@@ -305,6 +307,14 @@ assert_tpar = function(.tpar) {
305307
assert_string(.tpar[["grid.bg"]], null.ok = TRUE, name = "grid.bg")
306308
assert_numeric(.tpar[["fmar"]], len = 4, null.ok = TRUE, name = "fmar")
307309

310+
col.default = .tpar[["col.default"]]
311+
if (!is.null(col.default)) {
312+
if (!is.character(col.default) && !is.numeric(col.default)) {
313+
stop("col.default needs to be NULL, a (length-1) character colour, or a (length-1) numeric palette index", call. = FALSE)
314+
}
315+
assert_true(length(col.default) == 1, name = "length(col.default)==1")
316+
}
317+
308318
facet.col = .tpar[["facet.col"]]
309319
if (!is.null(facet.col)) {
310320
if (!is.null(facet.col) && !is.numeric(facet.col) && !is.character(facet.col)) {
@@ -367,6 +377,10 @@ init_tpar = function(rm_hook = FALSE) {
367377
.tpar$grid.lty = if (is.null(getOption("tinyplot_grid.lty"))) "dotted" else getOption("tinyplot_grid.lty")
368378
.tpar$grid.lwd = if (is.null(getOption("tinyplot_grid.lwd"))) 1 else as.numeric(getOption("tinyplot_grid.lwd"))
369379

380+
# Default colour for single-group displays (NULL defers to the first
381+
# qualitative palette colour, or base palette()[1] if no theme is active)
382+
.tpar$col.default = if (is.null(getOption("tinyplot_col.default"))) NULL else getOption("tinyplot_col.default")
383+
370384
# Legend justification
371385
.tpar$ljust = if (is.null(getOption("tinyplot_ljust"))) "left" else getOption("tinyplot_ljust")
372386

R/type_barplot.R

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,11 @@ data_barplot = function(width = 5/6, beside = FALSE, center = FALSE, offset = NU
280280
## default color palette
281281
ngrps = length(unique(datapoints$by))
282282
if (ngrps == 1L && null_palette) {
283-
if (is.null(col)) col = par("fg")
284-
if (is.null(bg)) bg = "grey"
283+
# With a theme palette active, leave bg = NULL so the fill tracks
284+
# the resolved border colour (see by_bg). Otherwise use the neutral
285+
# "lightgray" shared by all single-group area fills (matches base R
286+
# hist()/boxplot()).
287+
if (is.null(bg) && is.null(get_tpar("palette.qualitative", default = NULL))) bg = "lightgray"
285288
} else {
286289
if (is.null(bg)) bg = "by"
287290
}

R/type_boxplot.R

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,9 @@ data_boxplot = function(boxwex = 0.8) {
113113

114114
# Check if user provided palette before substitute)
115115
if (length(unique(datapoints[["by"]])) == 1 && null_palette) {
116-
if (is.null(col)) col = par("fg")
117-
if (is.null(bg)) bg = "lightgray"
116+
# With a theme palette active, leave bg = NULL so the fill tracks
117+
# the resolved border colour (see by_bg). Otherwise use neutral grey.
118+
if (is.null(bg) && is.null(get_tpar("palette.qualitative", default = NULL))) bg = "lightgray"
118119
} else {
119120
if (is.null(bg)) bg = "by"
120121
}

R/type_histogram.R

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,20 @@ data_histogram = function(breaks = "Sturges",
101101
hright = right
102102

103103
fun = function(settings, .breaks = hbreaks, .freebreaks = hfree.breaks, .freq = hfreq, .right = hright, .drop.zeros = hdrop.zeros, ...) {
104-
env2env(settings, environment(), c("palette", "bg", "col", "plot", "datapoints", "ymin", "ymax", "xmin", "xmax", "freq", "ylab", "xlab", "facet", "ribbon.alpha"))
104+
env2env(settings, environment(), c("palette", "bg", "col", "plot", "datapoints", "ymin", "ymax", "xmin", "xmax", "freq", "ylab", "xlab", "facet", "ribbon.alpha", "by", "null_by", "null_palette"))
105105

106106
hbreaks = ifelse(!sapply(.breaks, is.null), .breaks, "Sturges")
107107

108-
if (is.null(by) && is.null(palette)) {
109-
if (is.null(col)) col = par("fg")
110-
if (is.null(bg)) bg = "lightgray"
111-
} else {
112-
if (is.null(bg)) bg = ribbon.alpha
108+
# Multi-group displays fill from the palette at `ribbon.alpha`
109+
# transparency via the `ribbon.alpha` keyword. For single-group displays
110+
# with a theme palette active we leave `bg = NULL` so the fill tracks the
111+
# resolved border colour (see by_bg), which honours `col.default`. With
112+
# no theme palette, single-group uses the neutral "lightgray" shared by
113+
# all single-group area fills (matches base R hist()).
114+
if (is.null(bg) && !null_by) {
115+
bg = ribbon.alpha
116+
} else if (is.null(bg) && null_by && null_palette && is.null(get_tpar("palette.qualitative", default = NULL))) {
117+
bg = "lightgray"
113118
}
114119

115120
if (!.freebreaks) xbreaks = hist(datapoints$x, breaks = hbreaks, right = .right, plot = FALSE)$breaks

0 commit comments

Comments
 (0)