-
Notifications
You must be signed in to change notification settings - Fork 106
/
colour-mapping.r
420 lines (379 loc) · 14.8 KB
/
colour-mapping.r
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
#' Colour mapping
#'
#' Conveniently maps data values (numeric or factor/character) to colours
#' according to a given palette, which can be provided in a variety of formats.
#'
#' `col_numeric` is a simple linear mapping from continuous numeric data
#' to an interpolated palette.
#'
#' @param palette The colours or colour function that values will be mapped to
#' @param domain The possible values that can be mapped.
#'
#' For `col_numeric` and `col_bin`, this can be a simple numeric
#' range (e.g. `c(0, 100)`); `col_quantile` needs representative
#' numeric data; and `col_factor` needs categorical data.
#'
#' If `NULL`, then whenever the resulting colour function is called, the
#' `x` value will represent the domain. This implies that if the function
#' is invoked multiple times, the encoding between values and colours may not
#' be consistent; if consistency is needed, you must provide a non-`NULL`
#' domain.
#' @param na.color The colour to return for `NA` values. Note that
#' `na.color = NA` is valid.
#' @param alpha Whether alpha channels should be respected or ignored. If `TRUE`
#' then colors without explicit alpha information will be treated as fully
#' opaque.
#' @param reverse Whether the colors (or color function) in `palette` should be
#' used in reverse order. For example, if the default order of a palette goes
#' from blue to green, then `reverse = TRUE` will result in the colors going
#' from green to blue.
#' @return A function that takes a single parameter `x`; when called with a
#' vector of numbers (except for `col_factor`, which expects
#' factors/characters), #RRGGBB colour strings are returned (unless
#' `alpha = TRUE` in which case #RRGGBBAA may also be possible).
#'
#' @export
col_numeric <- function(palette, domain, na.color = "#808080", alpha = FALSE, reverse = FALSE) {
rng <- NULL
if (length(domain) > 0) {
rng <- range(domain, na.rm = TRUE)
if (!all(is.finite(rng))) {
stop("Wasn't able to determine range of domain")
}
}
pf <- safePaletteFunc(palette, na.color, alpha)
withColorAttr("numeric", list(na.color = na.color), function(x) {
if (length(x) == 0 || all(is.na(x))) {
return(pf(x))
}
if (is.null(rng)) rng <- range(x, na.rm = TRUE)
rescaled <- rescale(x, from = rng)
if (any(rescaled < 0 | rescaled > 1, na.rm = TRUE))
warning("Some values were outside the color scale and will be treated as NA", call. = FALSE)
if (reverse) {
rescaled <- 1 - rescaled
}
pf(rescaled)
})
}
# Attach an attribute colorType to a color function f so we can derive legend
# items from it
withColorAttr <- function(type, args = list(), fun) {
structure(fun, colorType = type, colorArgs = args)
}
# domain may or may not be NULL.
# Iff domain is non-NULL, x may be NULL.
# bins is non-NULL. It may be a scalar value (# of breaks) or a set of breaks.
getBins <- function(domain, x, bins, pretty) {
if (is.null(domain) && is.null(x)) {
stop("Assertion failed: domain and x can't both be NULL")
}
# Hard-coded bins
if (length(bins) > 1) {
return(bins)
}
if (bins < 2) {
stop("Invalid bins value of ", bins, "; bin count must be at least 2")
}
if (pretty) {
base::pretty(domain %||% x, n = bins)
} else {
rng <- range(domain %||% x, na.rm = TRUE)
seq(rng[1], rng[2], length.out = bins + 1)
}
}
#' @details `col_bin` also maps continuous numeric data, but performs
#' binning based on value (see the [base::cut()] function). `col_bin`
#' defaults for the `cut` function are `include.lowest = TRUE` and
#' `right = FALSE`.
#' @param bins Either a numeric vector of two or more unique cut points or a
#' single number (greater than or equal to 2) giving the number of intervals
#' into which the domain values are to be cut.
#' @param pretty Whether to use the function [pretty()] to generate
#' the bins when the argument `bins` is a single number. When
#' `pretty = TRUE`, the actual number of bins may not be the number of
#' bins you specified. When `pretty = FALSE`, [seq()] is used
#' to generate the bins and the breaks may not be "pretty".
#' @param right parameter supplied to [base::cut()]. See Details
#' @rdname col_numeric
#' @export
col_bin <- function(palette, domain, bins = 7, pretty = TRUE,
na.color = "#808080", alpha = FALSE, reverse = FALSE, right = FALSE) {
# domain usually needs to be explicitly provided (even if NULL) but not if
# breaks are specified
if (missing(domain) && length(bins) > 1) {
domain <- NULL
}
autobin <- is.null(domain) && length(bins) == 1
if (!is.null(domain))
bins <- getBins(domain, NULL, bins, pretty)
numColors <- if (length(bins) == 1) bins else length(bins) - 1
colorFunc <- col_factor(palette, domain = if (!autobin) 1:numColors,
na.color = na.color, alpha = alpha, reverse = reverse)
pf <- safePaletteFunc(palette, na.color, alpha)
withColorAttr("bin", list(bins = bins, na.color = na.color, right = right), function(x) {
if (length(x) == 0 || all(is.na(x))) {
return(pf(x))
}
binsToUse <- getBins(domain, x, bins, pretty)
ints <- cut(x, binsToUse, labels = FALSE, include.lowest = TRUE, right = right)
if (any(is.na(x) != is.na(ints)))
warning("Some values were outside the color scale and will be treated as NA", call. = FALSE)
colorFunc(ints)
})
}
#' @details `col_quantile` similarly bins numeric data, but via the
#' [stats::quantile()] function.
#' @param n Number of equal-size quantiles desired. For more precise control,
#' use the `probs` argument instead.
#' @param probs See [stats::quantile()]. If provided, the `n`
#' argument is ignored.
#' @rdname col_numeric
#' @export
col_quantile <- function(palette, domain, n = 4,
probs = seq(0, 1, length.out = n + 1), na.color = "#808080", alpha = FALSE,
reverse = FALSE, right = FALSE) {
if (!is.null(domain)) {
bins <- stats::quantile(domain, probs, na.rm = TRUE, names = FALSE)
return(withColorAttr(
"quantile", list(probs = probs, na.color = na.color, right = right),
col_bin(palette, domain = NULL, bins = bins, na.color = na.color,
alpha = alpha, reverse = reverse)
))
}
# I don't have a precise understanding of how quantiles are meant to map to colors.
# If you say probs = seq(0, 1, 0.25), which has length 5, does that map to 4 colors
# or 5? 4, right?
colorFunc <- col_factor(palette, domain = 1:(length(probs) - 1),
na.color = na.color, alpha = alpha, reverse = reverse)
withColorAttr("quantile", list(probs = probs, na.color = na.color, right = right), function(x) {
binsToUse <- stats::quantile(x, probs, na.rm = TRUE, names = FALSE)
ints <- cut(x, binsToUse, labels = FALSE, include.lowest = TRUE, right = right)
if (any(is.na(x) != is.na(ints)))
warning("Some values were outside the color scale and will be treated as NA", call. = FALSE)
colorFunc(ints)
})
}
# If already a factor, return the levels. Otherwise, convert to factor then
# return the levels.
calcLevels <- function(x, ordered) {
if (is.null(x)) {
NULL
} else if (is.factor(x)) {
levels(x)
} else if (ordered) {
unique(x)
} else {
sort(unique(x))
}
}
getLevels <- function(domain, x, lvls, ordered) {
if (!is.null(lvls)) {
return(lvls)
}
if (!is.null(domain)) {
return(calcLevels(domain, ordered))
}
if (!is.null(x)) {
return(calcLevels(x, ordered))
}
}
#' @details `col_factor` maps factors to colours. If the palette is
#' discrete and has a different number of colours than the number of factors,
#' interpolation is used.
#' @param levels An alternate way of specifying levels; if specified, domain is
#' ignored
#' @param ordered If `TRUE` and `domain` needs to be coerced to a
#' factor, treat it as already in the correct order
#' @rdname col_numeric
#' @export
col_factor <- function(palette, domain, levels = NULL, ordered = FALSE,
na.color = "#808080", alpha = FALSE, reverse = FALSE) {
# domain usually needs to be explicitly provided (even if NULL) but not if
# levels are specified
if (missing(domain) && !is.null(levels)) {
domain <- NULL
}
if (!is.null(levels) && anyDuplicated(levels)) {
warning("Duplicate levels detected", call. = FALSE)
levels <- unique(levels)
}
lvls <- getLevels(domain, NULL, levels, ordered)
force(palette) # palette loses scope
withColorAttr("factor", list(na.color = na.color), function(x) {
if (length(x) == 0 || all(is.na(x))) {
return(rep.int(na.color, length(x)))
}
lvls <- getLevels(domain, x, lvls, ordered)
pf <- safePaletteFunc(palette, na.color, alpha, nlevels = length(lvls) * ifelse(reverse, -1, 1))
origNa <- is.na(x)
x <- match(as.character(x), lvls)
if (any(is.na(x) != origNa)) {
warning("Some values were outside the color scale and will be treated as NA", call. = FALSE)
}
scaled <- rescale(as.integer(x), from = c(1, length(lvls)))
if (any(scaled < 0 | scaled > 1, na.rm = TRUE)) {
warning("Some values were outside the color scale and will be treated as NA", call. = FALSE)
}
if (reverse) {
scaled <- 1 - scaled
}
pf(scaled)
})
}
#' @details The `palette` argument can be any of the following:
#' \enumerate{
#' \item{A character vector of RGB or named colours. Examples: `palette()`, `c("#000000", "#0000FF", "#FFFFFF")`, `topo.colors(10)`}
#' \item{The name of an RColorBrewer palette, e.g. `"BuPu"` or `"Greens"`.}
#' \item{The full name of a viridis palette: `"viridis"`, `"magma"`, `"inferno"`, or `"plasma"`.}
#' \item{A function that receives a single value between 0 and 1 and returns a colour. Examples: `colorRamp(c("#000000", "#FFFFFF"), interpolate="spline")`.}
#' }
#' @examples
#' pal <- col_bin("Greens", domain = 0:100)
#' show_col(pal(sort(runif(10, 60, 100))))
#'
#' # Exponential distribution, mapped continuously
#' show_col(col_numeric("Blues", domain = NULL)(sort(rexp(16))))
#' # Exponential distribution, mapped by interval
#' show_col(col_bin("Blues", domain = NULL, bins = 4)(sort(rexp(16))))
#' # Exponential distribution, mapped by quantile
#' show_col(col_quantile("Blues", domain = NULL)(sort(rexp(16))))
#'
#' # Categorical data; by default, the values being coloured span the gamut...
#' show_col(col_factor("RdYlBu", domain = NULL)(LETTERS[1:5]))
#' # ...unless the data is a factor, without droplevels...
#' show_col(col_factor("RdYlBu", domain = NULL)(factor(LETTERS[1:5], levels=LETTERS)))
#' # ...or the domain is stated explicitly.
#' show_col(col_factor("RdYlBu", levels = LETTERS)(LETTERS[1:5]))
#' @rdname col_numeric
#' @name col_numeric
NULL
safePaletteFunc <- function(pal, na.color, alpha, nlevels = NULL) {
filterRange(
filterNA(
na.color = na.color,
filterZeroLength(
filterRGB(
toPaletteFunc(pal, alpha = alpha, nlevels = nlevels)
)
)
)
)
}
toPaletteFunc <- function(pal, alpha, nlevels) {
UseMethod("toPaletteFunc")
}
# Strings are interpreted as color names, unless length is 1 and it's the name
# of an RColorBrewer palette that is marked as qualitative
toPaletteFunc.character <- function(pal, alpha, nlevels) {
if (length(pal) == 1 && pal %in% row.names(RColorBrewer::brewer.pal.info)) {
paletteInfo <- RColorBrewer::brewer.pal.info[pal, ]
if (!is.null(nlevels)) {
# brewer_pal will return NAs if you ask for more colors than the palette has
colors <- brewer_pal(palette = pal)(abs(nlevels))
colors <- colors[!is.na(colors)]
} else {
colors <- brewer_pal(palette = pal)(RColorBrewer::brewer.pal.info[pal, "maxcolors"]) # Get all colors
}
} else if (length(pal) == 1 && pal %in% c("viridis", "magma", "inferno", "plasma")) {
colors <- viridis_pal(option = pal)(256)
} else {
colors <- pal
}
colour_ramp(colors, alpha = alpha)
}
# Accept colorRamp style matrix
toPaletteFunc.matrix <- function(pal, alpha, nlevels) {
toPaletteFunc(grDevices::rgb(pal, maxColorValue = 255), alpha = alpha)
}
# If a function, just assume it's already a function over [0-1]
toPaletteFunc.function <- function(pal, alpha, nlevels) {
pal
}
# colorRamp(space = 'Lab') throws error when called with
# zero-length input
filterZeroLength <- function(f) {
force(f)
function(x) {
if (length(x) == 0) {
character(0)
} else {
f(x)
}
}
}
# Wraps an underlying non-NA-safe function (like colorRamp).
filterNA <- function(f, na.color) {
force(f)
function(x) {
results <- character(length(x))
nas <- is.na(x)
results[nas] <- na.color
results[!nas] <- f(x[!nas])
results
}
}
# Wraps a function that may return RGB color matrix instead of rgb string.
filterRGB <- function(f) {
force(f)
function(x) {
results <- f(x)
if (is.character(results)) {
results
} else if (is.matrix(results)) {
grDevices::rgb(results, maxColorValue = 255)
} else {
stop("Unexpected result type ", class(x)[[1]])
}
}
}
filterRange <- function(f) {
force(f)
function(x) {
x[x < 0 | x > 1] <- NA
f(x)
}
}
#' Fast colour interpolation
#'
#' Returns a function that maps the interval \[0,1] to a set of colours.
#' Interpolation is performed in the CIELAB colour space. Similar to
#' \code{\link[grDevices]{colorRamp}(space = 'Lab')}, but hundreds of
#' times faster, and provides results in `"#RRGGBB"` (or
#' `"#RRGGBBAA"`) character form instead of RGB colour matrices.
#'
#' @param colors Colours to interpolate; must be a valid argument to
#' [grDevices::col2rgb()]. This can be a character vector of
#' `"#RRGGBB"` or `"#RRGGBBAA"`, colour names from
#' [grDevices::colors()], or a positive integer that indexes into
#' [grDevices::palette()].
#' @param na.color The colour to map to `NA` values (for example,
#' `"#606060"` for dark grey, or `"#00000000"` for transparent) and
#' values outside of \[0,1]. Can itself by `NA`, which will simply cause
#' an `NA` to be inserted into the output.
#' @param alpha Whether to include alpha transparency channels in interpolation.
#' If `TRUE` then the alpha information is included in the interpolation.
#' The returned colours will be provided in `"#RRGGBBAA"` format when needed,
#' i.e., in cases where the colour is not fully opaque, so that the `"AA"`
#' part is not equal to `"FF"`. Fully opaque colours will be returned in
#' `"#RRGGBB"` format. If `FALSE`, the alpha information is discarded
#' before interpolation and colours are always returned as `"#RRGGBB"`.
#'
#' @return A function that takes a numeric vector and returns a character vector
#' of the same length with RGB or RGBA hex colours.
#'
#' @seealso \code{\link[grDevices]{colorRamp}}
#'
#' @export
colour_ramp <- function(colors, na.color = NA, alpha = TRUE) {
if (length(colors) == 0) {
stop("Must provide at least one colour to create a colour ramp")
}
colorMatrix <- grDevices::col2rgb(colors, alpha = alpha)
structure(
function(x) {
doColorRamp(colorMatrix, x, alpha, ifelse(is.na(na.color), "", na.color))
},
safe_palette_func = TRUE
)
}