Nothing
ENCHARTER_PLOT_TYPES <- c(
"barChart", "lineChart", "areaChart", "scatterChart",
"pieChart", "doughnutChart", "radarChart", "bubbleChart", "stockChart", "ofPieChart",
"bar3DChart", "line3DChart", "area3DChart", "pie3DChart", "surfaceChart", "surface3DChart"
)
ENCHARTER_PLOT_PIES <- c("pieChart", "doughnutChart", "ofPieChart", "pie3DChart")
# Office theme colors, used for wb_color(theme = ...) and "auto"
ENCHARTER_THEME_HEX <- c(
bg1 = "FFFFFF", tx1 = "000000", bg2 = "E7E6E6", tx2 = "44546A",
accent1 = "4472C4", accent2 = "ED7D31", accent3 = "A5A5A5",
accent4 = "FFC000", accent5 = "5B9BD5", accent6 = "70AD47",
hlink = "0563C1", folHlink = "954F72", phClr = "000000",
dk1 = "000000", lt1 = "FFFFFF", dk2 = "44546A", lt2 = "E7E6E6"
)
# theme colors of the workbook being plotted; plot() sets them from the
# workbook's theme part and restores the Office defaults afterwards
plot_state <- new.env(parent = emptyenv())
plot_state$theme <- ENCHARTER_THEME_HEX
plot_state$chart_size <- c(480, 300)
plot_state$text_style <- list()
plot_set_theme <- function(wb) {
theme <- ENCHARTER_THEME_HEX
xml <- if (!is.null(wb)) wb$theme else NULL
if (is.character(xml) && length(xml) == 1 && nzchar(xml)) {
scheme <- xml_find_first(read_xml(xml), ".//a:clrScheme")
if (!is_missing(scheme)) {
for (nm in c("dk1", "lt1", "dk2", "lt2", paste0("accent", 1:6), "hlink", "folHlink")) {
node <- xml_find_first(scheme, paste0("./a:", nm))
if (is_missing(node)) next
srgb <- xml_find_first(node, "./a:srgbClr")
sys <- xml_find_first(node, "./a:sysClr")
hex <- if (!is_missing(srgb)) xml_attr(srgb, "val") else if (!is_missing(sys)) xml_attr(sys, "lastClr") else ""
if (nzchar(hex)) theme[[nm]] <- toupper(hex)
}
theme[["tx1"]] <- theme[["dk1"]]
theme[["bg1"]] <- theme[["lt1"]]
theme[["tx2"]] <- theme[["dk2"]]
theme[["bg2"]] <- theme[["lt2"]]
}
}
plot_state$theme <- theme
}
# Converts an encharter color (hex, AARRGGBB hex, "auto", "none", wbColour)
# to an R color string; NA for no fill.
plot_color <- function(x, default = "#000000") {
if (is.null(x) || length(x) == 0) return(default)
if (inherits(x, "wbColour")) {
type <- names(x)
if (identical(type, "theme")) {
val <- as.character(x)
if (!val %in% names(plot_state$theme)) val <- names(plot_state$theme)[as.integer(val) + 1]
lum_mod <- attr(x, "lumMod")
lum_off <- attr(x, "lumOff")
x <- plot_state$theme[[val]]
if (!is.null(lum_mod) || !is.null(lum_off)) {
# DrawingML lumMod/lumOff act on the HSL luminance
rgb <- grDevices::col2rgb(paste0("#", x)) / 255
mx <- max(rgb)
mn <- min(rgb)
l <- (mx + mn) / 2
sat <- if (mx == mn) 0 else (mx - mn) / (1 - abs(2 * l - 1))
h <- if (mx == mn) 0 else if (mx == rgb[1]) ((rgb[2] - rgb[3]) / (mx - mn)) %% 6 else if (mx == rgb[2]) (rgb[3] - rgb[1]) / (mx - mn) + 2 else (rgb[1] - rgb[2]) / (mx - mn) + 4
l <- min(1, max(0, l * (lum_mod %||% 1) + (lum_off %||% 0)))
c1 <- (1 - abs(2 * l - 1)) * sat
x1 <- c1 * (1 - abs(h %% 2 - 1))
base <- switch(floor(h) + 1, c(c1, x1, 0), c(x1, c1, 0), c(0, c1, x1), c(0, x1, c1), c(x1, 0, c1), c(c1, 0, x1))
x <- toupper(substr(grDevices::rgb(base[1] + l - c1 / 2, base[2] + l - c1 / 2, base[3] + l - c1 / 2), 2, 7))
}
} else if (identical(type, "auto")) {
x <- "auto"
} else {
x <- as.character(x[1])
x <- sub("^FF(?=[0-9A-Fa-f]{6}$)", "", x, perl = TRUE)
}
}
x <- as.character(x[1])
if (is.na(x)) return(default)
if (tolower(x) == "auto") return(paste0("#", plot_state$theme[["accent1"]]))
if (tolower(x) == "none") return(NA_character_)
hex <- toupper(sub("^#", "", x))
if (nchar(hex) == 8) return(paste0("#", substr(hex, 3, 8), substr(hex, 1, 2)))
if (nchar(hex) == 6) return(paste0("#", hex))
default
}
# Automatic point colors: the six accents, then the same accents
# with the brightness variations of the default color style
plot_auto_color <- function(i, palette) {
if (i <= length(palette)) return(plot_color(palette[i], "#4472C4"))
accents <- plot_state$theme[paste0("accent", 1:6)]
# the colors of the points 7 to 9 as rendered with the current Office theme
if (i <= 9 && identical(unname(accents[1]), "156082")) {
return(c("#8599AA", "#EEA18A", "#869E87")[i - 6])
}
base <- grDevices::col2rgb(paste0("#", accents[(i - 1) %% 6 + 1])) / 255
cycle <- (i - 1) %/% 6
mod <- c(1, 0.6, 0.8, 0.8, 0.6, 0.5)[min(cycle + 1, 6)]
off <- c(0, 0, 0.2, 0, 0.4, 0)[min(cycle + 1, 6)]
rgb <- pmin(1, base * mod + off)
grDevices::rgb(rgb[1], rgb[2], rgb[3])
}
plot_lty <- function(type) {
if (is.null(type)) return("solid")
switch(type,
dash = , dashed = "dashed",
dot = , dotted = , sysDot = "dotted",
dashDot = "dotdash",
lgDash = "longdash",
lgDashDot = "twodash",
sysDash = "22",
"solid"
)
}
plot_pch <- function(symbol) {
switch(symbol %||% "none",
circle = 21, square = 22, diamond = 23, triangle = 24,
x = 4, plus = 3, star = 8, dash = 45, dot = 20, NA_integer_
)
}
plot_gpar_text <- function(style, default_size, default_col = "#000000") {
# text without its own properties takes the chart defaults; titles keep
# their larger size
ts <- plot_state$text_style
style <- Filter(Negate(is.null), style %||% list())
style <- utils::modifyList(ts[setdiff(names(ts), if (default_size > 10) "font_size")], style)
face <- if (isTRUE(style$bold) && isTRUE(style$italic)) "bold.italic"
else if (isTRUE(style$bold)) "bold"
else if (isTRUE(style$italic)) "italic"
else "plain"
# theme font placeholders such as "+mn-lt" are not font families
family <- style$font_name %||% ""
if (startsWith(family, "+")) family <- ""
grid::gpar(
fontsize = style$font_size %||% default_size,
fontface = face,
col = plot_color(style$font_color %||% style$color, default_col),
fontfamily = family
)
}
# Formats numbers roughly the way a spreadsheet does for a handful of common format
# codes; everything else falls back to "General".
plot_format <- function(x, format = NULL) {
if (inherits(x, c("Date", "POSIXt"))) {
# without a format the cell's short date is shown, which follows the
# system locale
if (is.null(format)) return(format(x, "%x"))
fmt <- tolower(format)
fmt <- gsub("yyyy", "%Y", fmt)
fmt <- gsub("yy", "%y", fmt)
fmt <- gsub("mmmm", "%B", fmt)
fmt <- gsub("mmm", "%b", fmt)
fmt <- gsub("mm", "%m", fmt)
fmt <- gsub("dd", "%d", fmt)
fmt <- gsub("hh", "%H", fmt)
fmt <- gsub("ss", "%S", fmt)
fmt <- gsub("%m:%m", "%M:%M", fmt, fixed = TRUE)
fmt <- gsub("%H:%m", "%H:%M", fmt, fixed = TRUE)
fmt <- gsub("\\\\", "", fmt)
return(format(x, fmt))
}
if (is.character(x)) return(x)
fmt <- format %||% "General"
# sections: positive;negative;zero — the negative section shows the
# magnitude, its sign comes from the format itself; an empty section hides
# the value
sections <- strsplit(paste0(fmt, ";"), ";", fixed = TRUE)[[1]]
out <- rep("", length(x))
for (k in seq_along(x)) {
v <- x[k]
if (is.na(v)) next
sec <- if (length(sections) >= 3 && v == 0) sections[3] else if (length(sections) >= 2 && v < 0) sections[2] else sections[1]
if (length(sections) >= 2 && v < 0) v <- -v
if (!nzchar(sec)) next
if (sec == "General") {
txt <- formatC(v, digits = 10, format = "fg")
if (grepl(".", txt, fixed = TRUE)) txt <- sub("\\.?0+$", "", txt)
out[k] <- trimws(txt)
next
}
# split the section into literal text around the digit pattern
chars <- strsplit(sec, "")[[1]]
pre <- ""
post <- ""
pattern <- ""
pct <- FALSE
i <- 1
while (i <= length(chars)) {
ch <- chars[i]
lit <- ""
if (ch == "\\") {
lit <- if (i < length(chars)) chars[i + 1] else ""
i <- i + 2
} else if (ch == "\"") {
j <- i + 1
while (j <= length(chars) && chars[j] != "\"") j <- j + 1
lit <- paste(chars[seq_len(j - i - 1) + i], collapse = "")
i <- j + 1
} else if (ch %in% c("#", "0", "?", ",", ".")) {
pattern <- paste0(pattern, ch)
i <- i + 1
next
} else if (ch %in% c("_", "*")) {
lit <- if (ch == "_") " " else ""
i <- i + 2
} else if (ch == "[") {
j <- i
while (j <= length(chars) && chars[j] != "]") j <- j + 1
i <- j + 1
next
} else if (ch == "%") {
pct <- TRUE
lit <- "%"
i <- i + 1
} else if (ch %in% c("E", "e")) {
i <- i + 1
next
} else {
lit <- ch
i <- i + 1
}
if (nzchar(pattern)) post <- paste0(post, lit) else pre <- paste0(pre, lit)
}
if (pct) v <- v * 100
if (!nzchar(pattern)) {
out[k] <- paste0(pre, post)
next
}
big <- if (grepl(",", pattern, fixed = TRUE)) "," else ""
dec <- regmatches(pattern, regexpr("\\.[0#?]+", pattern))
digits <- if (length(dec)) nchar(dec) - 1L else 0L
# halves round away from zero, as in spreadsheets
v <- sign(v) * floor(abs(v) * 10^digits + 0.5 + 1e-9) / 10^digits
txt <- formatC(v, format = "f", digits = digits, big.mark = big)
out[k] <- paste0(pre, trimws(txt), post)
}
out
}
# The automatic value axis. The minimum is zero for all-positive data
# unless the values sit in the upper sixth of their magnitude, the ends are
# padded by 5% of the range, and the major unit is the power of ten below
# the padded range, divided by 2, 5 or 10 when the range covers less than
# 5, 2 or 1 of those units. Checked against Excel output for a dozen charts.
# `pad = FALSE` is used for percent and radar axes, which end exactly at the
# data maximum.
plot_scale <- function(lo, hi, params = list(), pad = TRUE) {
if (!is.null(params$log_base)) {
base <- params$log_base
lo <- max(lo, .Machine$double.eps)
mn <- params$min %||% base^floor(log(lo, base))
mx <- params$max %||% base^ceiling(log(hi, base))
return(list(min = mn, max = mx, major = params$major %||% base, log = base))
}
if (!is.finite(lo) || !is.finite(hi)) {
lo <- 0
hi <- 1
}
if (lo == hi) {
lo <- if (lo > 0) 0 else lo - 1
hi <- if (hi > 0) hi else 0
}
rng <- hi - lo
if (lo > 0 && rng <= hi / 6) {
# values well away from zero: the axis starts below them by half
# their range and ends a tenth above
lo_pad <- lo - 0.5 * rng
hi_pad <- hi + 0.1 * rng
} else if (hi < 0 && rng <= abs(lo) / 6) {
hi_pad <- hi + 0.5 * rng
lo_pad <- lo - 0.1 * rng
} else {
if (lo > 0) lo <- 0
if (hi < 0) hi <- 0
rng <- hi - lo
hi_pad <- if (hi > 0 && pad) hi + 0.05 * rng else hi
lo_pad <- if (lo < 0 && pad) lo - 0.05 * rng else lo
}
hi_pad <- params$max %||% hi_pad
lo_pad <- params$min %||% lo_pad
major <- params$major
if (is.null(major)) {
span <- hi_pad - lo_pad
mag <- 10^floor(log10(span))
ratio <- span / mag
major <- if (ratio <= 1) mag / 10 else if (ratio < 2) mag / 5 else if (ratio < 5) mag / 2 else mag
}
mn <- params$min %||% (floor(lo_pad / major) * major)
mx <- params$max %||% (ceiling(hi_pad / major) * major)
if (mx <= mn) mx <- mn + major
list(min = mn, max = mx, major = major, log = NULL)
}
plot_ticks <- function(scale) {
if (!is.null(scale$log)) {
return(scale$log^seq(floor(log(scale$min, scale$log)), ceiling(log(scale$max, scale$log))))
}
t <- seq(scale$min, scale$max, by = scale$major)
if (abs(t[length(t)] - scale$max) > 1e-9 * scale$major) t <- c(t, scale$max)
t
}
# Reads a range such as "'Sheet 1'!$B$2:$B$7" from the workbook.
plot_read_ref <- function(wb, ref, levels = FALSE) {
if (is.null(wb) || is.null(ref) || !grepl("!", ref)) return(NULL)
sheet <- gsub("^'|'$", "", sub("!.*$", "", ref))
sheet <- gsub("''", "'", sheet)
dims <- gsub("\\$", "", sub("^.*!", "", ref))
if (!sheet %in% wb$get_sheet_names(escape = TRUE)) return(NULL)
df <- openxlsx2::wb_to_df(wb, sheet = sheet, dims = dims, col_names = FALSE)
if (isTRUE(levels) && nrow(df) >= ncol(df) && ncol(df) > 1) {
# a multi-level category range: one column per level, outer level first
return(as.data.frame(lapply(df, function(col) as.character(col)), stringsAsFactors = FALSE))
}
vals <- if (nrow(df) >= ncol(df)) df[[1]] else unlist(df[1, ], use.names = FALSE)
if (is.factor(vals)) vals <- as.character(vals)
vals
}
# Resolves values and names of every series
plot_collect <- function(chart, wb) {
out <- vector("list", length(chart$series_data))
for (i in seq_along(chart$series_data)) {
s <- chart$series_data[[i]]
y <- s$data_cache %||% plot_read_ref(wb, s$data)
x <- s$cat_cache %||% plot_read_ref(wb, s$label, levels = TRUE)
cat_levels <- NULL
if (is.data.frame(x)) {
cat_levels <- x
x <- x[[ncol(x)]]
}
z <- s$z_cache %||% plot_read_ref(wb, s$weight)
if (is.null(y)) {
stop(sprintf("series %d has no values; add it from wb_data() or pass 'wb'", i), call. = FALSE)
}
y <- suppressWarnings(as.numeric(y))
if (!is.null(z)) z <- suppressWarnings(as.numeric(z))
name <- s$name_cache %||% s$name
if (!is.null(name) && grepl("!.+", name)) {
cell <- plot_read_ref(wb, name)
name <- if (is.null(cell)) paste("Series", i) else as.character(cell[1])
}
if (is.null(name) || is.na(name)) name <- paste("Series", i)
if (is.null(x)) x <- seq_along(y)
if (length(x) < length(y)) x <- c(x, rep(NA, length(y) - length(x)))
s$values <- y
s$cats <- x
s$cat_levels <- cat_levels
s$sizes <- z
s$label_text <- xml_unescape(name)
out[[i]] <- s
}
out
}
# Legend: returns its size in points and a drawing function. A horizontal
# legend wider than `max_w` is broken into several rows.
plot_legend <- function(entries, params, style, max_w = Inf) {
pos <- params$pos %||% "r"
if (pos == "tr") pos <- "r"
gp <- plot_gpar_text(style, if (length(style)) 10 else 9)
n <- length(entries)
key_w <- 18
pad <- 6
widths <- vapply(entries, function(e) {
grid::convertWidth(grid::grobWidth(grid::textGrob(e$label, gp = gp)), "points", valueOnly = TRUE)
}, numeric(1))
item_w <- widths + key_w + 2 * pad
line_h <- gp$fontsize * 1.4
horizontal <- pos %in% c("t", "b")
# rows: horizontal legends fill each row up to max_w, vertical ones use one
# entry per row
rows <- list()
if (horizontal) {
current <- integer()
used <- 0
for (k in seq_len(n)) {
if (length(current) && used + item_w[k] > max_w) {
rows[[length(rows) + 1]] <- current
current <- integer()
used <- 0
}
current <- c(current, k)
used <- used + item_w[k]
}
rows[[length(rows) + 1]] <- current
size <- c(w = max(vapply(rows, function(r) sum(item_w[r]), numeric(1))), h = length(rows) * line_h + pad)
} else {
rows <- as.list(seq_len(n))
size <- c(w = max(item_w), h = n * line_h + pad)
}
draw <- function() {
y <- size[["h"]] - pad / 2
for (r in rows) {
cy <- y - line_h / 2
y <- y - line_h
x <- if (horizontal) (size[["w"]] - sum(item_w[r])) / 2 + pad else pad
for (k in r) {
e <- entries[[k]]
if (e$kind == "line") {
grid::grid.lines(x = grid::unit(c(x, x + key_w - 4), "points"), y = grid::unit(c(cy, cy), "points"),
gp = grid::gpar(col = e$col, lwd = e$lwd, lty = e$lty))
if (!is.na(e$pch)) {
grid::grid.points(x = grid::unit(x + (key_w - 4) / 2, "points"), y = grid::unit(cy, "points"),
pch = e$pch, size = grid::unit(e$cex / 0.75, "points"),
gp = grid::gpar(col = e$mcol, fill = e$mfill, lwd = 1))
}
} else {
grid::grid.rect(x = grid::unit(x + 4, "points"), y = grid::unit(cy, "points"),
width = grid::unit(8, "points"), height = grid::unit(8, "points"),
just = c("center", "center"), gp = grid::gpar(fill = e$col, col = NA))
}
grid::grid.text(e$label, x = grid::unit(x + key_w, "points"), y = grid::unit(cy, "points"),
just = c("left", "center"), gp = gp)
x <- x + item_w[k]
}
}
}
list(pos = pos, size = size, draw = draw)
}
# Title text broken into lines that fit `width` points, the way titles wrap
# a title that is wider than the chart
plot_title_lines <- function(title, default_size, width) {
gp <- plot_gpar_text(title$style, default_size)
out <- character()
for (para in strsplit(plot_title_text(title), "\n", fixed = TRUE)[[1]]) {
words <- strsplit(para, " ", fixed = TRUE)[[1]]
line <- ""
for (w in words) {
trial <- if (nzchar(line)) paste(line, w) else w
wide <- grid::convertWidth(grid::grobWidth(grid::textGrob(trial, gp = gp)), "points", valueOnly = TRUE) > width
if (wide && nzchar(line)) {
out <- c(out, line)
line <- w
} else {
line <- trial
}
}
out <- c(out, line)
}
out
}
plot_title_height <- function(title, default_size, width = Inf) {
if (is.null(title$text)) return(0)
gp <- plot_gpar_text(title$style, default_size)
gp$fontsize * 1.2 * length(plot_title_lines(title, default_size, width)) + 8
}
plot_title_text <- function(title) {
txt <- title$text
if (inherits(txt, "fmt_txt")) return(as.character(txt))
xml_unescape(as.character(txt))
}
# Draws the chart title in the current viewport (a strip at the top)
plot_draw_title <- function(title, default_size, width = Inf) {
if (is.null(title$text)) return(invisible())
grid::grid.text(paste(plot_title_lines(title, default_size, width), collapse = "\n"),
gp = plot_gpar_text(title$style, default_size, "#000000"))
}
plot_axis_gp_line <- function(p) {
grid::gpar(col = plot_color(p$color, "#000000"), lwd = (p$line_width %||% 1) * 96 / 72)
}
plot_grid_gp <- function(p, minor = FALSE) {
key <- if (minor) "minor_" else ""
style <- p[[paste0(key, "grid_lines")]]
grid::gpar(
col = plot_color(p[[paste0(key, "grid_color")]], if (minor) "#F2F2F2" else "#D9D9D9"),
lwd = (p[[paste0(key, "grid_width")]] %||% if (minor) 0.5 else 1) * 96 / 72,
lty = if (is.character(style)) plot_lty(style) else "solid"
)
}
# Marker for a line/scatter point
plot_draw_markers <- function(x, y, m, series_col) {
pch <- plot_pch(m$symbol)
if (is.na(pch)) return(invisible())
fill <- plot_color(m$fill, series_col)
line <- plot_color(m$line$color, series_col)
# grid draws a symbol at 3/4 of `size`; the marker size is the diameter in points
grid::grid.points(x, y, pch = pch, size = grid::unit((m$size %||% 5) / 0.75, "points"),
gp = grid::gpar(col = line, fill = fill, lwd = (m$line$width %||% 0.75) * 96 / 72),
default.units = "native")
}
plot_trend_curve <- function(x, y, tl, shift = 0) {
ok <- is.finite(x) & is.finite(y)
x <- x[ok] + shift
y <- y[ok]
if (length(x) < 2) return(NULL)
type <- tl$type %||% "linear"
xs <- seq(min(x) - (tl$backward %||% 0), max(x) + (tl$forward %||% 0), length.out = 100)
new <- data.frame(x = xs)
# a set intercept is taken out of the values before the fit
b0 <- tl$intercept
k <- min(tl$order %||% 2, length(x) - 1) # nolint
fit <- switch(type,
linear = if (is.null(b0)) stats::predict(stats::lm(y ~ x), new)
else b0 + stats::predict(stats::lm(I(y - b0) ~ x - 1), new),
poly = if (is.null(b0)) stats::predict(stats::lm(y ~ stats::poly(x, k, raw = TRUE)), new)
else b0 + stats::predict(stats::lm(I(y - b0) ~ stats::poly(x, k, raw = TRUE) - 1), new),
exp = if (is.null(b0)) exp(stats::predict(stats::lm(log(y) ~ x), new))
else b0 * exp(stats::predict(stats::lm(I(log(y) - log(b0)) ~ x - 1), new)),
log = stats::predict(stats::lm(y ~ log(x)), new),
power = exp(stats::predict(stats::lm(log(y) ~ log(x)), new)),
movingAvg = {
p <- tl$period %||% 2
xs <- x[p:length(x)]
vapply(p:length(y), function(i) mean(y[(i - p + 1):i]), numeric(1))
},
NULL
)
if (is.null(fit)) return(NULL)
list(x = xs - shift, y = as.numeric(fit))
}
# Legend text of a trendline
plot_trend_name <- function(tl, series_name) {
if (!is.null(tl$name)) return(tl$name)
kind <- switch(tl$type %||% "linear",
linear = "Linear", poly = "Poly.", exp = "Expon.", log = "Log.", power = "Power",
movingAvg = sprintf("%d per. Mov. Avg.", tl$period %||% 2), "Linear"
)
sprintf("%s (%s)", kind, series_name)
}
# Equation text of a trendline, with 4 decimals
plot_trend_equation <- function(x, y, tl) {
ok <- is.finite(x) & is.finite(y)
x <- x[ok]
y <- y[ok]
if (length(x) < 2) return(NULL)
# the default label shows six characters: five significant digits, or
# four decimals below one, without trailing zeros
num <- function(v) format(if (abs(v) >= 1) signif(v, 5) else round(v, 4), scientific = FALSE, trim = TRUE, drop0trailing = TRUE)
term <- function(coef, txt, first = FALSE) {
sign <- if (coef < 0) "- " else if (first) "" else "+ "
paste0(sign, num(abs(coef)), txt)
}
type <- tl$type %||% "linear"
b0 <- tl$intercept
k <- min(tl$order %||% 2, length(x) - 1)
fit <- switch(type,
linear = if (is.null(b0)) stats::lm(y ~ x) else stats::lm(I(y - b0) ~ x - 1),
poly = if (is.null(b0)) stats::lm(y ~ stats::poly(x, k, raw = TRUE)) else stats::lm(I(y - b0) ~ stats::poly(x, k, raw = TRUE) - 1),
exp = if (all(y > 0)) (if (is.null(b0)) stats::lm(log(y) ~ x) else stats::lm(I(log(y) - log(b0)) ~ x - 1)),
log = if (all(x > 0)) stats::lm(y ~ log(x)),
power = if (all(x > 0 & y > 0)) stats::lm(log(y) ~ log(x)),
NULL
)
if (is.null(fit)) return(NULL)
b <- stats::coef(fit)
# the set intercept takes its place among the coefficients
if (!is.null(b0) && type %in% c("linear", "poly", "exp")) b <- c(if (type == "exp") log(b0) else b0, b)
eq <- switch(type,
linear = paste("y =", term(b[2], "x", TRUE), term(b[1], "")),
poly = {
k <- length(b) - 1
parts <- character()
for (p in k:1) {
power <- if (p == 1) "x" else paste0("x", intToUtf8(c(0xB2, 0xB3, 0x2074, 0x2075, 0x2076)[p - 1]))
parts <- c(parts, term(b[p + 1], power, p == k))
}
paste("y =", paste(c(parts, term(b[1], "")), collapse = " "))
},
exp = paste0("y = ", num(exp(b[1])), "e", term(b[2], "x", TRUE)),
log = paste("y =", term(b[2], "ln(x)", TRUE), term(b[1], "")),
power = paste0("y = ", num(exp(b[1])), "x^", num(b[2]))
)
# with a set intercept the label still shows the R-squared of the free
# fit, as the spreadsheet application does
r2 <- if (is.null(b0)) summary(fit)$r.squared else summary(switch(type,
linear = stats::lm(y ~ x),
poly = stats::lm(y ~ stats::poly(x, k, raw = TRUE)),
exp = stats::lm(log(y) ~ x)
))$r.squared
list(eq = eq, r2 = paste0("R\u00b2 = ", num(r2)))
}
plot_error_extent <- function(y, eb) {
type <- eb$type %||% "fixedVal"
val <- eb$value %||% 5
switch(type,
fixedVal = rep(val, length(y)),
percentage = abs(y) * val / 100,
stdDev = rep(stats::sd(y, na.rm = TRUE) * val, length(y)),
stdErr = rep(stats::sd(y, na.rm = TRUE) / sqrt(sum(!is.na(y))), length(y)),
rep(val, length(y))
)
}
# Draws error bars around points at (x, y) in native units
plot_draw_error_bars <- function(x, y, s, horizontal = FALSE) {
eb <- s$error_bars
if (!is.list(eb)) return(invisible())
ext <- plot_error_extent(y, eb)
if (identical(eb$type, "stdDev")) y <- rep(mean(y, na.rm = TRUE), length(y))
dir <- eb$direction %||% "both"
up <- if (dir %in% c("both", "plus")) y + ext else y
lo <- if (dir %in% c("both", "minus")) y - ext else y
gp <- grid::gpar(col = plot_color(eb$color, "#000000"), lwd = 1)
cap <- grid::unit(c(-3, 3), "points")
for (i in seq_along(x)) {
if (!is.finite(x[i]) || !is.finite(y[i])) next
if (horizontal) {
grid::grid.lines(grid::unit(c(lo[i], up[i]), "native"), grid::unit(c(x[i], x[i]), "native"), gp = gp)
for (v in c(lo[i], up[i])) {
grid::grid.lines(grid::unit(c(v, v), "native"), grid::unit(c(x[i], x[i]), "native") + cap, gp = gp)
}
} else {
grid::grid.lines(grid::unit(c(x[i], x[i]), "native"), grid::unit(c(lo[i], up[i]), "native"), gp = gp)
for (v in c(lo[i], up[i])) {
grid::grid.lines(grid::unit(c(x[i], x[i]), "native") + cap, grid::unit(c(v, v), "native"), gp = gp)
}
}
}
}
# Data label text for a point
plot_label_text <- function(lp, cat, val, pct = NULL, name = NULL, sep = lp$sep %||% ", ", size = NULL) {
parts <- character()
if (isTRUE(lp$show_ser_name) && !is.null(name)) parts <- c(parts, name)
if (isTRUE(lp$show_cat)) parts <- c(parts, plot_format(cat))
if (isTRUE(lp$show_val)) parts <- c(parts, plot_format(val, lp$format))
if (isTRUE(lp$show_percent) && !is.null(pct)) parts <- c(parts, plot_format(pct, lp$format %||% "0%"))
if (isTRUE(lp$show_bubble_size) && !is.null(size)) parts <- c(parts, plot_format(size))
paste(parts, collapse = sep)
}
plot_labels_on <- function(lp) {
isTRUE(lp$show_val) || isTRUE(lp$show_cat) || isTRUE(lp$show_percent) || isTRUE(lp$show_ser_name) || isTRUE(lp$show_bubble_size)
}
# ---------------------------------------------------------------------------
# Cartesian charts: bar, line, area, scatter, bubble
# ---------------------------------------------------------------------------
plot_cartesian <- function(chart, series) {
is_xy <- any(vapply(series, function(s) s$type %in% c("scatterChart", "bubbleChart"), logical(1)))
horizontal <- any(vapply(series, function(s) s$type == "barChart" && identical(s$dir, "bar"), logical(1)))
lp <- chart$label_params
label_gp <- plot_gpar_text(lp$style, 9, "#000000")
# ---- categories / x values ----
if (is_xy) {
cats <- NULL
x_of <- function(s) {
x <- suppressWarnings(as.numeric(s$cats))
if (all(is.na(x))) x <- seq_along(s$values)
x
}
} else {
cats <- series[[1]]$cats
n_cat <- max(vapply(series, function(s) length(s$values), integer(1)))
if (length(cats) < n_cat) cats <- c(cats, rep(NA, n_cat - length(cats)))
x_of <- function(s) seq_along(s$values) - 0.5
}
# with the value axis crossing at the categories ("midCat") the points
# of line and area charts sit on the tick marks, the first one on the
# axis; bars keep their slots
mid_cat <- !is_xy && identical(chart$axis_params$y$cross_between, "midCat") &&
!any(vapply(series, function(s) s$type == "barChart", logical(1)))
if (mid_cat) x_of <- function(s) seq_along(s$values) - 1
# a trendline extrapolated forwards adds empty categories to the axis
n_slots <- length(cats)
for (s in series) {
if (is.list(s$trendline) && s$type != "scatterChart") n_slots <- max(n_slots, length(cats) + (s$trendline$forward %||% 0))
}
if (!is_xy && n_slots > length(cats)) cats <- c(cats, rep(NA, n_slots - length(cats)))
# A date axis places the points by date in date_unit units (days, months or
# years, as set or as inferred from the spacing of the dates). Each unit
# is one slot; points and bars sit in the middle of their slot.
# a date axis needs referenced date categories; literal dates and
# multi-level categories are text
px_auto <- chart$axis_params$x$auto
is_date <- !is_xy && inherits(cats, c("Date", "POSIXt")) && !horizontal && !isFALSE(px_auto) &&
!is.null(series[[1]]$label) && is.null(series[[1]]$cat_levels)
if (is_date) mid_cat <- FALSE
if (is_date) {
px_date <- chart$axis_params$x
dates <- as.Date(cats)
date_unit <- px_date$base_time
if (is.null(date_unit)) {
gaps <- diff(sort(unique(as.numeric(dates))))
med <- if (length(gaps)) stats::median(gaps) else 1
date_unit <- if (med >= 365) "years" else if (med >= 28) "months" else "days"
}
serial_date <- function(v) as.Date(v, origin = "1899-12-30")
d_min <- if (!is.null(px_date$min)) serial_date(px_date$min) else min(dates, na.rm = TRUE)
d_max <- if (!is.null(px_date$max)) serial_date(px_date$max) else max(dates, na.rm = TRUE)
origin <- switch(date_unit,
days = d_min,
months = as.Date(format(d_min, "%Y-%m-01")),
years = as.Date(format(d_min, "%Y-01-01"))
)
to_units <- function(d) {
d <- as.Date(d)
y <- as.integer(format(d, "%Y"))
m <- as.integer(format(d, "%m"))
switch(date_unit,
days = as.numeric(d) - as.numeric(origin),
months = (y * 12 + m) - (as.integer(format(origin, "%Y")) * 12 + as.integer(format(origin, "%m"))) +
(as.integer(format(d, "%d")) - 1) / 31,
years = y - as.integer(format(origin, "%Y")) + (as.integer(format(d, "%j")) - 1) / 365
)
}
from_units <- function(u) {
switch(date_unit,
days = origin + u,
months = {
m0 <- as.integer(format(origin, "%Y")) * 12 + as.integer(format(origin, "%m")) - 1 + u
as.Date(sprintf("%d-%02d-01", m0 %/% 12, m0 %% 12 + 1))
},
years = as.Date(sprintf("%d-01-01", as.integer(format(origin, "%Y")) + u))
)
}
unit_in_base <- function(unit) {
if (is.null(unit) || unit == date_unit) return(1)
days <- c(days = 1, months = 30.4375, years = 365.25)
days[[unit]] / days[[date_unit]]
}
date_span <- floor(to_units(d_max)) + 1
x_of <- function(s) {
u <- suppressWarnings(floor(to_units(s$cats)) + 0.5)
length(u) <- length(s$values)
u
}
}
# ---- value ranges per axis (stacking taken into account) ----
axis_range <- function(sel) {
if (!length(sel)) return(c(NA, NA))
lo <- Inf
hi <- -Inf
for (grp in split(sel, vapply(sel, function(s) paste(s$type, s$grouping), character(1)))) {
if (grp[[1]]$grouping %in% c("stacked", "percentStacked") && grp[[1]]$type %in% c("barChart", "areaChart", "lineChart")) {
mat <- do.call(rbind, lapply(grp, function(s) {
v <- s$values
length(v) <- length(cats)
v
}))
mat[is.na(mat)] <- 0
if (grp[[1]]$grouping == "percentStacked") {
tot <- colSums(abs(mat))
mat <- sweep(mat, 2, ifelse(tot == 0, 1, tot), "/")
}
if (grp[[1]]$type == "barChart") {
pos <- colSums(pmax(mat, 0))
neg <- colSums(pmin(mat, 0))
lo <- min(lo, neg)
hi <- max(hi, pos)
} else {
cum <- apply(mat, 2, cumsum)
lo <- min(lo, cum, 0)
hi <- max(hi, cum, 0)
}
} else {
for (s in grp) {
v <- s$values
if (is.list(s$error_bars)) {
ext <- plot_error_extent(v, s$error_bars)
v <- c(v, v + ext, v - ext)
}
# a forecast trendline counts towards the axis range
if (is.list(s$trendline) && ((s$trendline$forward %||% 0) > 0 || (s$trendline$backward %||% 0) > 0)) {
tc <- plot_trend_curve(x_of(s), s$values, s$trendline, shift = if (is_xy || is_date) 0 else 0.5)
if (!is.null(tc)) v <- c(v, tc$y)
}
lo <- min(lo, v, na.rm = TRUE)
hi <- max(hi, v, na.rm = TRUE)
}
}
}
if (any(vapply(sel, function(s) s$type %in% c("barChart", "areaChart"), logical(1)))) {
lo <- min(lo, 0)
hi <- max(hi, 0)
}
c(lo, hi)
}
prim <- Filter(function(s) !s$sec_type %in% c("y", "xy"), series)
sec <- Filter(function(s) s$sec_type %in% c("y", "xy"), series)
pct_prim <- length(prim) && all(vapply(prim, function(s) identical(s$grouping, "percentStacked"), logical(1)))
pct_sec <- length(sec) && all(vapply(sec, function(s) identical(s$grouping, "percentStacked"), logical(1)))
r1 <- axis_range(prim)
y1 <- plot_scale(r1[1], r1[2], chart$axis_params$y, pad = !pct_prim)
y2 <- NULL
if (length(sec) || !is.null(chart$y2_title$text)) {
r2 <- if (length(sec)) axis_range(sec) else r1
y2 <- plot_scale(r2[1], r2[2], chart$axis_params$y2, pad = !pct_sec)
}
on_x2 <- vapply(series, function(s) s$sec_type %in% c("x", "xy"), logical(1))
x2 <- NULL
if (is_xy) {
xs <- unlist(lapply(series[!on_x2], x_of))
if (!length(xs)) xs <- unlist(lapply(series, x_of))
# forecast trendlines extend the x axis
for (s in series) {
if (is.list(s$trendline)) xs <- c(xs, min(xs) - (s$trendline$backward %||% 0), max(xs) + (s$trendline$forward %||% 0))
}
xa <- plot_scale(min(xs, na.rm = TRUE), max(xs, na.rm = TRUE), chart$axis_params$x)
if (any(on_x2) || !is.null(chart$x2_title$text)) {
xs2 <- if (any(on_x2)) unlist(lapply(series[on_x2], x_of)) else xs
x2 <- plot_scale(min(xs2, na.rm = TRUE), max(xs2, na.rm = TRUE), chart$axis_params$x2)
}
} else if (is_date) {
step <- chart$axis_params$x$major
if (!is.null(step)) {
step <- step * unit_in_base(chart$axis_params$x$major_time)
} else {
candidates <- switch(date_unit,
days = c(1, 2, 3, 4, 5, 7, 10, 14, 21, 28, 61, 91, 182, 365, 730),
months = c(1, 2, 3, 4, 6, 12, 24, 60, 120),
years = c(1, 2, 5, 10, 20, 50, 100)
)
for (cand in candidates) {
if (date_span / cand <= 25) {
step <- cand
break
}
}
if (is.null(step)) step <- candidates[length(candidates)] * ceiling(date_span / 25 / candidates[length(candidates)])
}
xa <- list(min = 0, max = date_span, major = step, log = NULL)
} else {
xa <- list(min = 0, max = if (mid_cat) max(1, n_slots - 1) else n_slots, major = 1, log = NULL)
}
px <- chart$axis_params$x
px2 <- chart$axis_params$x2
py <- chart$axis_params$y
py2 <- chart$axis_params$y2
# transform for log axes: native coordinates are log-scaled
# values at or below zero have no place on a logarithmic axis
tr <- function(v, sc) {
if (is.null(sc$log)) return(v)
out <- rep(NA_real_, length(v))
ok <- !is.na(v) & v > 0
out[ok] <- log(v[ok], sc$log)
out
}
lim <- function(sc, p = NULL) {
l <- sort(tr(c(sc$min, sc$max), sc))
if (isTRUE(p$rev)) rev(l) else l
}
# ---- tick labels ----
# OOXML rotation is clockwise, grid rotation counter-clockwise; Excel
# writes rot="-60000000" (outside the valid range) for automatic rotation
rot_auto <- is.null(px$rotation) || abs(px$rotation) > 90
rot_x <- if (rot_auto) 0 else -px$rotation
if (is_xy) {
x_ticks <- plot_ticks(xa)
x_lab_at <- x_ticks
x_labels <- plot_format(x_ticks, px$format)
} else if (is_date) {
x_ticks <- seq(0, date_span, by = xa$major)
# tick marks sit between the base units, labels in the middle of them
x_lab_at <- x_ticks[x_ticks < date_span] + 0.5
x_labels <- plot_format(from_units(x_lab_at - 0.5), px$format)
# date labels turn upright when they do not fit side by side
gp_tmp <- plot_gpar_text(px, 10)
lab_w <- max(vapply(x_labels, function(l) grid::convertWidth(grid::grobWidth(grid::textGrob(l, gp = gp_tmp)), "points", valueOnly = TRUE), numeric(1)))
avail <- grid::convertWidth(grid::unit(1, "npc"), "points", valueOnly = TRUE) / max(1, length(x_ticks))
if (rot_auto && lab_w + 4 > avail) rot_x <- 90
} else {
x_ticks <- seq_along(cats) - if (mid_cat) 1 else 0.5
x_lab_at <- x_ticks
x_labels <- plot_format(cats, px$format)
x_labels[is.na(cats)] <- ""
}
x_skip <- px$tick_lbl_skip
if (!is_xy && !is_date && is.null(x_skip) && !horizontal && rot_x == 0) {
gp_tmp <- plot_gpar_text(px, 10)
lab_w <- max(vapply(x_labels, function(l) grid::convertWidth(grid::grobWidth(grid::textGrob(l, gp = gp_tmp)), "points", valueOnly = TRUE), numeric(1)))
avail <- grid::convertWidth(grid::unit(1, "npc"), "points", valueOnly = TRUE) / max(1, length(cats))
if (lab_w + 4 > avail) x_skip <- ceiling((lab_w + 4) / avail)
}
if (!is_xy && !is.null(x_skip) && x_skip > 1) {
keep <- seq(1, length(x_labels), by = x_skip)
x_labels[-keep] <- ""
}
disp_divisor <- function(p) {
du <- p$disp_units
if (is.null(du)) return(1)
if (is.numeric(du)) return(du)
c(hundreds = 1e2, thousands = 1e3, tenThousands = 1e4, hundredThousands = 1e5, millions = 1e6,
tenMillions = 1e7, hundredMillions = 1e8, billions = 1e9, trillions = 1e12)[[du]]
}
y_ticks <- plot_ticks(y1)
y_labels <- plot_format(y_ticks / disp_divisor(py), py$format %||% if (pct_prim) "0%" else NULL)
y2_ticks <- if (is.null(y2)) NULL else plot_ticks(y2)
y2_labels <- if (is.null(y2)) NULL else plot_format(y2_ticks / disp_divisor(py2), py2$format %||% if (pct_sec) "0%" else NULL)
x2_ticks <- if (is.null(x2)) NULL else plot_ticks(x2)
x2_labels <- if (is.null(x2)) NULL else plot_format(x2_ticks, px2$format)
if (identical(px$label_pos, "none") || isTRUE(px$delete)) x_labels <- rep("", length(x_labels))
# category labels wrap to the room they have: the left margin of a
# horizontal bar chart, the category slot of a vertical one
if (!is_xy && !is_date && rot_x == 0 && length(x_labels)) {
chart_wd <- plot_state$chart_size[1]
room <- if (horizontal) {
if (identical(chart$plot_layout$target, "inner")) chart$plot_layout$x * chart_wd - 10 else chart_wd / 3
} else {
chart_wd * (chart$plot_layout$w %||% 0.85) / max(1, n_slots) * (px$tick_lbl_skip %||% 1) - 4
}
gp_tmp <- plot_gpar_text(px, 10)
for (i in seq_along(x_labels)) {
if (!grepl(" ", x_labels[i], fixed = TRUE)) next
wide <- grid::convertWidth(grid::grobWidth(grid::textGrob(x_labels[i], gp = gp_tmp)), "points", valueOnly = TRUE)
if (wide > room) x_labels[i] <- paste(plot_title_lines(list(text = x_labels[i], style = px), 10, room), collapse = "\n")
}
}
if (identical(py$label_pos, "none") || isTRUE(py$delete)) y_labels <- rep("", length(y_labels))
x_gp <- plot_gpar_text(px, 10, "#000000")
x2_gp <- plot_gpar_text(px2, 10, "#000000")
y_gp <- plot_gpar_text(py, 10, "#000000")
y2_gp <- plot_gpar_text(py2, 10, "#000000")
text_w <- function(labels, gp) {
if (!length(labels)) return(0)
max(vapply(labels, function(l) grid::convertWidth(grid::grobWidth(grid::textGrob(l, gp = gp)), "points", valueOnly = TRUE), numeric(1)))
}
text_h <- function(gp) grid::convertHeight(grid::grobHeight(grid::textGrob("Xg", gp = gp)), "points", valueOnly = TRUE)
# the primary value axis sits at the right edge when the categories are
# reversed or it is set to cross at the maximum, but not both
y_side <- if (xor(isTRUE(px$rev), identical(py$crosses, "max"))) "right" else "left"
# ---- margins around the plot area ----
if (horizontal) {
left_w <- text_w(x_labels, x_gp) + 8
bottom_h <- text_h(y_gp) + 8
right_w <- if (is.null(y2)) 4 else 4
top_h <- if (is.null(y2)) 4 else text_h(y2_gp) + 8
} else {
y_w <- text_w(y_labels, y_gp) + 8
y2_w <- if (is.null(y2)) 0 else text_w(y2_labels, y2_gp) + 8
left_w <- if (y_side == "left") y_w else 4
right_w <- max(4, if (y_side == "right") y_w else 0) + y2_w
# labels may span several lines
lab_h <- text_h(x_gp)
if (length(x_labels)) {
lab_h <- max(lab_h, vapply(x_labels, function(l) grid::convertHeight(grid::grobHeight(grid::textGrob(l, gp = x_gp)), "points", valueOnly = TRUE), numeric(1)))
}
lab_w <- text_w(x_labels, x_gp)
bottom_h <- if (rot_x != 0) abs(sin(rot_x * pi / 180)) * lab_w + abs(cos(rot_x * pi / 180)) * lab_h + 8 else lab_h + 8
outer_levels <- if (!is_xy && !is_date && is.data.frame(series[[1]]$cat_levels)) ncol(series[[1]]$cat_levels) - 1 else 0
bottom_h <- bottom_h + outer_levels * (lab_h + 6)
top_h <- if (is.null(x2)) 4 else text_h(x2_gp) + 8
}
# a data table hangs below the category labels, one row per series and
# a column with the legend keys and names to the left of the plot area
data_table <- isTRUE(chart$show_data_table) && !is_xy && !horizontal
if (data_table) {
dt_row_h <- text_h(x_gp) + 6
dt_hdr_h <- bottom_h
dt_names <- vapply(series, function(s) s$label_text, character(1))
dt_name_w <- text_w(dt_names, x_gp) + 20
bottom_h <- bottom_h + length(series) * dt_row_h
left_w <- max(left_w, dt_name_w)
}
xt_h <- plot_title_height(chart$x_title, 10)
x2t_h <- if (is.null(x2)) 0 else plot_title_height(chart$x2_title, 10)
yt_w <- plot_title_height(chart$y_title, 10)
y2t_w <- plot_title_height(chart$y2_title, 10)
if (horizontal) {
left_w <- left_w + xt_h
bottom_h <- bottom_h + yt_w
} else {
bottom_h <- bottom_h + xt_h
left_w <- left_w + yt_w
right_w <- right_w + y2t_w
top_h <- top_h + x2t_h
}
# value axis crossing: where the category axis line sits
cross_val <- function(sc, p, default_max = FALSE) {
if (!is.null(p$crosses_at)) return(tr(p$crosses_at, sc))
crosses <- p$crosses %||% if (default_max) "max" else "autoZero"
l <- lim(sc)
if (crosses == "min") return(l[1])
if (crosses == "max") return(l[2])
if (!is.null(sc$log)) return(l[1])
min(max(0, l[1]), l[2])
}
# with a fixed inner plot area the labels sit outside the given rectangle
if (identical(chart$plot_layout$target, "inner")) {
left_w <- 0
right_w <- 0
top_h <- 0
bottom_h <- 0
}
# ---- viewport for the plot area ----
outer <- grid::viewport(layout = grid::grid.layout(
3, 3,
widths = grid::unit(c(left_w, 1, right_w), c("points", "null", "points")),
heights = grid::unit(c(top_h, 1, bottom_h), c("points", "null", "points"))
))
grid::pushViewport(outer)
xlim <- if (horizontal) lim(y1, py) else lim(xa, px)
ylim <- if (horizontal) lim(xa, px) else lim(y1, py)
plot_vp <- grid::viewport(layout.pos.row = 2, layout.pos.col = 2, xscale = xlim, yscale = ylim, name = "plot")
grid::pushViewport(plot_vp)
# plot area fill
ps <- chart$plot_style
if (!is.null(ps$fill) || !is.null(ps$line)) {
grid::grid.rect(gp = grid::gpar(fill = plot_color(ps$fill, NA), col = plot_color(ps$line, NA), lwd = (ps$line_width %||% 1) * 96 / 72))
}
# coordinates: cx = category/x direction, cy = value direction
at <- function(cx, cy) if (horizontal) list(x = cy, y = cx) else list(x = cx, y = cy)
# ---- gridlines ----
draw_grid <- function(sc, p, along_value) {
for (minor in c(FALSE, TRUE)) {
style <- p[[if (minor) "minor_grid_lines" else "grid_lines"]]
if (is.null(style) || isFALSE(style)) next
if (minor) {
step <- p$minor %||% (sc$major / 5)
ticks <- seq(sc$min, sc$max, by = step)
} else {
ticks <- plot_ticks(sc)
}
gp <- plot_grid_gp(p, minor)
for (t in tr(ticks, sc)) {
if (along_value == !horizontal) {
grid::grid.lines(x = grid::unit(c(0, 1), "npc"), y = grid::unit(c(t, t), "native"), gp = gp)
} else {
grid::grid.lines(x = grid::unit(c(t, t), "native"), y = grid::unit(c(0, 1), "npc"), gp = gp)
}
}
}
}
draw_grid(y1, py, TRUE)
if (!is_xy) {
if (!isFALSE(px$grid_lines) && !is.null(px$grid_lines)) {
gp <- plot_grid_gp(px)
for (t in if (is_date || mid_cat) x_ticks else 0:length(cats)) {
if (horizontal) grid::grid.lines(x = grid::unit(c(0, 1), "npc"), y = grid::unit(c(t, t), "native"), gp = gp)
else grid::grid.lines(x = grid::unit(c(t, t), "native"), y = grid::unit(c(0, 1), "npc"), gp = gp)
}
}
} else {
draw_grid(xa, px, FALSE)
}
# ---- series ----
order_of <- c(areaChart = 1, barChart = 2, lineChart = 3, stockChart = 3, scatterChart = 4, bubbleChart = 4)
bar_groups <- Filter(function(s) s$type == "barChart", series)
bar_key <- vapply(bar_groups, function(s) paste(s$sec_type %in% c("y", "xy"), s$grouping), character(1))
labels_pending <- list()
scale_of <- function(s) if (s$sec_type %in% c("y", "xy") && !is.null(y2)) y2 else y1
y2lim <- if (is.null(y2)) NULL else lim(y2, py2)
x2lim <- if (is.null(x2)) NULL else lim(x2, px2)
push_scale <- function(s, clip = TRUE) {
sec_y <- s$sec_type %in% c("y", "xy") && !is.null(y2)
sec_x <- s$sec_type %in% c("x", "xy") && !is.null(x2)
if (horizontal) {
xsc <- if (sec_y) y2lim else xlim
ysc <- if (sec_x) x2lim else ylim
} else {
xsc <- if (sec_x) x2lim else xlim
ysc <- if (sec_y) y2lim else ylim
}
grid::pushViewport(grid::viewport(xscale = xsc, yscale = ysc, clip = if (clip) "on" else "inherit"))
}
# trendlines are fitted against 1, 2, ... on category axes
trend_shift <- if (is_xy || is_date) 0 else 0.5
trend_labels <- list()
stack_tops <- list()
# points of the line-type series, for high-low lines, drop lines and
# up-down bars
line_pts <- list()
for (s in series[order(order_of[vapply(series, function(s) s$type, character(1))])]) {
sc <- scale_of(s)
push_scale(s)
col <- s$auto_col
v <- s$values
xs <- x_of(s)
stacked <- s$grouping %in% c("stacked", "percentStacked") && s$type %in% c("barChart", "areaChart", "lineChart")
key <- paste(s$type, s$sec_type %in% c("y", "xy"))
base <- rep(0, length(v))
if (stacked) {
if (is.null(stack_tops[[key]])) stack_tops[[key]] <- list(pos = rep(0, length(cats)), neg = rep(0, length(cats)))
tops <- stack_tops[[key]]
vv <- v
if (s$grouping == "percentStacked") {
same_group <- Filter(function(o) paste(o$type, o$sec_type %in% c("y", "xy")) == key, series)
tot <- Reduce(`+`, lapply(same_group, function(o) {
a <- abs(o$values)
length(a) <- length(cats)
a[is.na(a)] <- 0
a
}))
vv <- v / tot[seq_along(v)]
vv[!is.finite(vv)] <- 0
}
vv[is.na(vv)] <- 0
idx <- seq_along(vv)
if (s$type == "barChart") {
base <- ifelse(vv >= 0, tops$pos[idx], tops$neg[idx])
top <- base + vv
tops$pos[idx] <- ifelse(vv >= 0, top, tops$pos[idx])
tops$neg[idx] <- ifelse(vv < 0, top, tops$neg[idx])
} else {
base <- tops$pos[idx]
top <- base + vv
tops$pos[idx] <- top
}
stack_tops[[key]] <- tops
v <- top
}
yv <- tr(v, sc)
if (s$type == "barChart") {
# every series has its own slot in the cluster; stacked series only
# sit on top of each other when the overlap says so (overlap = 100)
k_all <- bar_groups[bar_key == paste(s$sec_type %in% c("y", "xy"), s$grouping)]
k <- length(k_all)
j <- which(vapply(k_all, function(o) identical(o, s), logical(1)))
gap <- (s$gap_width %||% 150) / 100
ov <- (s$overlap %||% 0) / 100
w <- 1 / (k - (k - 1) * ov + gap)
left <- xs - 0.5 + gap / 2 * w + (j - 1) * (1 - ov) * w
b <- tr(pmax(base, sc$min), sc)
if (!is.null(sc$log)) b <- tr(sc$min, sc)
fill <- col
for (i in seq_along(v)) {
if (!is.finite(yv[i])) next
pt <- if (horizontal) list(x = grid::unit(min(b[i], yv[i]), "native"), y = grid::unit(left[i], "native"),
width = grid::unit(abs(yv[i] - b[i]), "native"), height = grid::unit(w, "native"))
else list(x = grid::unit(left[i], "native"), y = grid::unit(min(b[i], yv[i]), "native"),
width = grid::unit(w, "native"), height = grid::unit(abs(yv[i] - b[i]), "native"))
# inverted negative bars are white with a black outline; single
# points may carry their own fill
inverted <- v[i] < 0 && isTRUE(s$invert_if_negative)
pt_fill <- fill
border <- s$border
for (p in s$points) {
if (p$idx != i - 1) next
pt_fill <- plot_color(p$color, fill)
if (!is.null(p$border)) border <- p$border
}
if (is.na(pt_fill) && !is.list(border)) next
border_col <- if (inverted) "#000000" else if (is.list(border)) plot_color(border$color, NA) else NA
grid::grid.rect(x = pt$x, y = pt$y, width = pt$width, height = pt$height, just = c("left", "bottom"),
gp = grid::gpar(fill = if (inverted) "#FFFFFF" else pt_fill, col = border_col,
lwd = (if (is.list(border)) border$width %||% 0.75 else 0.75) * 96 / 72))
}
centers <- left + w / 2
plot_draw_error_bars(if (horizontal) centers else centers, yv, s, horizontal = horizontal)
# a series may carry its own label settings
slp <- s$label_params %||% lp
if (plot_labels_on(slp) || length(s$point_labels)) {
for (i in seq_along(v)) {
if (!is.finite(yv[i])) next
# a point may have its own label settings or no label at all
plp <- slp
for (p in s$point_labels) if (p$idx == i - 1) plp <- if (isTRUE(p$delete)) NULL else utils::modifyList(slp, p[!vapply(p, is.null, logical(1))])
if (is.null(plp) || !plot_labels_on(plp)) next
pos <- plp$pos %||% "t"
txt <- plot_label_text(plp, cats[i], s$values[i], name = s$label_text)
outward <- if (v[i] >= 0) c("center", "bottom") else c("center", "top")
inward <- if (v[i] >= 0) c("center", "top") else c("center", "bottom")
if (pos %in% c("t", "outEnd")) {
yy <- yv[i]
just <- outward
} else if (pos %in% c("b", "inBase")) {
yy <- b[i]
just <- outward
} else if (pos == "inEnd") {
yy <- yv[i]
just <- inward
} else {
yy <- (yv[i] + b[i]) / 2
just <- c("center", "center")
}
if (horizontal) {
hj <- c(if (just[2] == "bottom") "left" else if (just[2] == "top") "right" else "center", "center")
labels_pending[[length(labels_pending) + 1]] <- list(
x = yy, y = centers[i], txt = txt, just = hj, gp = plot_gpar_text(plp$style, 9, "#000000"),
dx = plp$dx, dy = plp$dy, fill = plp$fill, align = plp$style$align, key = if (isTRUE(plp$show_legend_key)) col
)
} else {
labels_pending[[length(labels_pending) + 1]] <- list(
x = centers[i], y = yy, txt = txt, just = just, gp = plot_gpar_text(plp$style, 9, "#000000"),
dx = plp$dx, dy = plp$dy, fill = plp$fill, align = plp$style$align, key = if (isTRUE(plp$show_legend_key)) col
)
}
}
}
if (is.list(s$trendline)) {
tc <- plot_trend_curve(xs, s$values, s$trendline, shift = trend_shift)
if (!is.null(tc)) {
grid::grid.lines(tc$x, tr(tc$y, sc), default.units = "native",
gp = grid::gpar(col = plot_color(s$trendline$color, col), lwd = 1.5 * 96 / 72))
trend_labels[[length(trend_labels) + 1]] <- list(s = s, tc = tc, xs = xs)
}
}
} else if (s$type == "areaChart") {
ok <- is.finite(yv)
bx <- tr(if (is.null(sc$log)) base else pmax(base, sc$min), sc)
poly_x <- c(xs[ok], rev(xs[ok]))
poly_y <- c(yv[ok], rev(bx[ok]))
a <- at(poly_x, poly_y)
grid::grid.polygon(a$x, a$y, default.units = "native", gp = grid::gpar(fill = col, col = NA))
} else {
# lineChart, scatterChart, bubbleChart
if (s$type == "bubbleChart") {
z <- s$sizes %||% abs(s$values)
zmax <- max(unlist(lapply(series, function(o) o$sizes %||% abs(o$values))), na.rm = TRUE)
maxd <- 0.25 * (chart$bubble_scale %||% 100) / 100
d <- if (identical(chart$size_represents, "w")) maxd * abs(z) / zmax else maxd * sqrt(abs(z) / zmax)
for (i in seq_along(v)) {
if (!is.finite(yv[i]) || !is.finite(xs[i]) || is.na(d[i])) next
if (z[i] < 0 && !isTRUE(chart$show_neg_bubbles)) next
grid::grid.circle(x = grid::unit(xs[i], "native"), y = grid::unit(yv[i], "native"),
r = grid::unit(d[i] / 2, "snpc"),
gp = grid::gpar(fill = plot_auto_color(i, chart$palette), col = NA))
}
} else {
if (s$type %in% c("lineChart", "stockChart")) line_pts[[length(line_pts) + 1]] <- list(xs = xs, yv = yv, s = s)
if (!isFALSE(s$line$show)) {
gp <- grid::gpar(col = col, lwd = (s$line$width %||% 1) * 96 / 72, lty = plot_lty(s$line$type), lineend = "round")
ok <- is.finite(yv) & is.finite(xs)
runs <- if (identical(chart$disp_blanks_as, "span")) list(which(ok)) else split(which(ok), cumsum(!ok)[ok])
for (r in runs) {
if (length(r) < 2) next
a <- at(xs[r], yv[r])
if (isTRUE(s$smooth)) grid::grid.xspline(a$x, a$y, shape = -0.5, open = TRUE, default.units = "native", gp = gp)
else grid::grid.lines(a$x, a$y, default.units = "native", gp = gp)
}
}
m <- s$marker
if (s$type == "scatterChart" && (is.null(m$symbol) || m$symbol == "none")) m$symbol <- "circle"
ok <- is.finite(yv) & is.finite(xs)
a <- at(xs[ok], yv[ok])
plot_draw_markers(a$x, a$y, m, col)
# single points with a marker of their own
for (p in s$points) {
if (is.null(p$marker) || p$idx + 1 > length(xs) || !ok[p$idx + 1]) next
pm <- utils::modifyList(m, p$marker[!vapply(p$marker, is.null, logical(1))])
if (identical(pm$symbol, "none")) next
a1 <- at(xs[p$idx + 1], yv[p$idx + 1])
plot_draw_markers(a1$x, a1$y, pm, plot_color(p$color, col))
}
if (is.list(s$error_bars) && identical(s$error_bars$axis, "x")) plot_draw_error_bars(yv, xs, s, horizontal = !horizontal)
else plot_draw_error_bars(xs, yv, s, horizontal = horizontal)
}
slp <- s$label_params %||% lp
if (plot_labels_on(slp) || length(s$point_labels)) {
for (i in seq_along(v)) {
if (!is.finite(yv[i]) || !is.finite(xs[i])) next
plp <- slp
for (p in s$point_labels) if (p$idx == i - 1) plp <- if (isTRUE(p$delete)) NULL else utils::modifyList(slp, p[!vapply(p, is.null, logical(1))])
if (is.null(plp) || !plot_labels_on(plp)) next
txt <- plot_label_text(plp, if (is_xy) xs[i] else cats[i], s$values[i], name = s$label_text, size = s$sizes[i])
pos <- plp$pos %||% "t"
just <- switch(pos, b = c("center", "top"), l = c("right", "center"), r = c("left", "center"), ctr = c("center", "center"), c("center", "bottom"))
# the label clears the marker or the bubble
mk <- s$marker
gap <- 4 + if (s$type == "bubbleChart") {
grid::convertWidth(grid::unit(d[i] / 2, "snpc"), "points", valueOnly = TRUE)
} else if (identical(mk$symbol, "none") || is.null(mk$symbol)) {
0
} else {
(mk$size %||% 5) / 2
}
off <- switch(pos, b = c(0, -gap), l = c(-gap, 0), r = c(gap, 0), ctr = c(0, 0), c(0, gap))
labels_pending[[length(labels_pending) + 1]] <- list(
x = xs[i], y = yv[i], txt = txt, just = just, off = off, gp = plot_gpar_text(plp$style, 9, "#000000"),
dx = plp$dx, dy = plp$dy, fill = plp$fill, align = plp$style$align, key = if (isTRUE(plp$show_legend_key)) col
)
}
}
if (is.list(s$trendline)) {
tc <- plot_trend_curve(xs, s$values, s$trendline, shift = trend_shift)
if (!is.null(tc)) {
a <- at(tc$x, tr(tc$y, sc))
grid::grid.lines(a$x, a$y, default.units = "native",
gp = grid::gpar(col = plot_color(s$trendline$color, col), lwd = 1.5 * 96 / 72))
trend_labels[[length(trend_labels) + 1]] <- list(s = s, tc = tc, xs = xs)
}
}
}
labels_pending <- lapply(labels_pending, function(l) {
if (is.null(l$sec)) l$sec <- s$sec_type
l
})
grid::upViewport()
}
# high-low lines join the highest and lowest series value of a category,
# drop lines fall from every point to the category axis, and up-down bars
# span the first and the last series (open and close of a stock chart)
if (length(line_pts) && (isTRUE(chart$high_low_lines) || isTRUE(chart$drop_lines) || isTRUE(chart$up_down_bars))) {
s1 <- line_pts[[1]]$s
sc <- scale_of(s1)
push_scale(s1)
n_pt <- max(vapply(line_pts, function(l) length(l$xs), integer(1)))
ys <- do.call(rbind, lapply(line_pts, function(l) {
y <- l$yv
length(y) <- n_pt
y
}))
xs1 <- line_pts[[1]]$xs
length(xs1) <- n_pt
lgp <- grid::gpar(col = "#000000", lwd = 0.75 * 96 / 72)
if (isTRUE(chart$drop_lines)) {
y0 <- tr(max(sc$min, min(0, sc$max)), sc)
for (l in line_pts) for (i in seq_along(l$xs)) {
if (!is.finite(l$yv[i])) next
a <- at(c(l$xs[i], l$xs[i]), c(y0, l$yv[i]))
grid::grid.lines(a$x, a$y, default.units = "native", gp = lgp)
}
}
if (isTRUE(chart$high_low_lines)) {
for (i in seq_len(n_pt)) {
y <- ys[, i]
if (sum(is.finite(y)) < 2) next
a <- at(c(xs1[i], xs1[i]), range(y, na.rm = TRUE))
grid::grid.lines(a$x, a$y, default.units = "native", gp = lgp)
}
}
if (isTRUE(chart$up_down_bars) && length(line_pts) >= 2) {
gap <- (s1$gap_width %||% 150) / 100
w <- 1 / (1 + gap)
y_open <- ys[1, ]
y_close <- ys[nrow(ys), ]
for (i in seq_len(n_pt)) {
if (!is.finite(y_open[i]) || !is.finite(y_close[i])) next
up <- y_close[i] >= y_open[i]
pt <- if (horizontal) list(x = grid::unit(min(y_open[i], y_close[i]), "native"), y = grid::unit(xs1[i] - w / 2, "native"),
width = grid::unit(abs(y_close[i] - y_open[i]), "native"), height = grid::unit(w, "native"))
else list(x = grid::unit(xs1[i] - w / 2, "native"), y = grid::unit(min(y_open[i], y_close[i]), "native"),
width = grid::unit(w, "native"), height = grid::unit(abs(y_close[i] - y_open[i]), "native"))
grid::grid.rect(x = pt$x, y = pt$y, width = pt$width, height = pt$height, just = c("left", "bottom"),
gp = grid::gpar(fill = if (up) "#FFFFFF" else "#404040", col = "#000000", lwd = 0.75 * 96 / 72))
}
}
grid::upViewport()
}
# equation and R-squared are shown unless they were switched off
for (tl in trend_labels) {
show_eq <- !isFALSE(tl$s$trendline$show_eq)
show_r2 <- !isFALSE(tl$s$trendline$show_r2)
if (!show_eq && !show_r2) next
txt <- plot_trend_equation(tl$xs + trend_shift, tl$s$values, tl$s$trendline)
if (is.null(txt)) next
lines <- c(if (show_eq) txt$eq, if (show_r2) txt$r2)
# the label sits above the end of the trendline; when that end
# leaves the plot area the label goes to the top left corner instead
sc <- scale_of(tl$s)
n_pts <- length(tl$tc$x)
end_y <- tr(tl$tc$y[n_pts], sc)
l <- lim(sc)
push_scale(tl$s, clip = FALSE)
if (is.finite(end_y) && end_y >= min(l) && end_y <= max(l) && tl$tc$x[n_pts] < lim(xa, px)[2]) {
a <- at(tl$tc$x[n_pts], end_y)
grid::grid.text(paste(lines, collapse = "\n"), x = grid::unit(a$x, "native"),
y = grid::unit(a$y, "native") + grid::unit(6, "points"), just = c("right", "bottom"),
gp = grid::gpar(fontsize = 9, col = "#000000"))
} else {
grid::grid.text(paste(lines, collapse = "\n"), x = grid::unit(0, "npc"), y = grid::unit(1, "npc") + grid::unit(4, "points"),
just = c("left", "bottom"), gp = grid::gpar(fontsize = 9, col = "#000000"))
}
grid::upViewport()
}
for (l in labels_pending) {
# a data label wraps when it is wider than a fifth of the chart
if (grepl(" ", l$txt, fixed = TRUE)) {
room <- plot_state$chart_size[1] / 5
if (grid::convertWidth(grid::grobWidth(grid::textGrob(l$txt, gp = l$gp %||% label_gp)), "points", valueOnly = TRUE) > room) {
l$txt <- paste(plot_title_lines(list(text = l$txt, style = list(font_size = (l$gp %||% label_gp)$fontsize)), 9, room), collapse = "\n")
}
}
off <- l$off %||% c(0, if (l$just[2] == "bottom") 3 else if (l$just[2] == "top") -3 else 0)
if (horizontal && is.null(l$off)) off <- c(if (l$just[1] == "left") 3 else if (l$just[1] == "right") -3 else 0, 0)
# manual offsets are fractions of the chart size, y downwards
off <- off + c((l$dx %||% 0) * plot_state$chart_size[1], -(l$dy %||% 0) * plot_state$chart_size[2])
push_scale(list(sec_type = l$sec), clip = FALSE)
lx <- grid::unit(l$x, "native") + grid::unit(off[1], "points")
ly <- grid::unit(l$y, "native") + grid::unit(off[2], "points")
if (!is.null(l$key)) {
# legend key: a square in the series color left of the text
tg <- grid::textGrob(l$txt, gp = l$gp %||% label_gp)
kx <- lx - grid::grobWidth(tg) * switch(l$just[1], left = 0, right = 1, 0.5) - grid::unit(7, "points")
grid::grid.rect(x = kx, y = ly, width = grid::unit(6, "points"), height = grid::unit(6, "points"), gp = grid::gpar(fill = l$key, col = NA))
lx <- lx + grid::unit(5, "points")
}
if (!is.null(l$fill)) {
# label background
tg <- grid::textGrob(l$txt, gp = l$gp %||% label_gp)
grid::grid.rect(x = lx, y = ly, width = grid::grobWidth(tg) + grid::unit(4, "points"), height = grid::grobHeight(tg) + grid::unit(3, "points"),
just = l$just, gp = grid::gpar(fill = plot_color(l$fill, NA), col = NA))
}
if (grepl("\n", l$txt, fixed = TRUE) && !is.null(l$align)) {
# the lines of a wrapped label are aligned within the label box
w <- grid::grobWidth(grid::textGrob(l$txt, gp = l$gp %||% label_gp))
shift <- switch(l$just[1], left = 0, right = 1, 0.5)
if (l$align == "l") {
lx <- lx - shift * w
l$just[1] <- "left"
} else if (l$align == "r") {
lx <- lx + (1 - shift) * w
l$just[1] <- "right"
} else {
lx <- lx + (0.5 - shift) * w
l$just[1] <- "center"
}
}
grid::grid.text(l$txt, x = lx, y = ly, just = l$just, gp = l$gp %||% label_gp)
grid::upViewport()
}
# ---- axes ----
# Tick marks: Excel treats a missing majorTickMark/minorTickMark as
# "cross"; encharter only writes them when set. Minor ticks sit at the
# minor unit (major / 5 by default), on log axes at 2..9 times a decade.
tick_ends <- function(style, len, outward) {
switch(style,
none = NULL,
out = c(0, outward * len),
"in" = c(0, -outward * len),
c(-len, len)
)
}
minor_positions <- function(sc, p) {
if (!is.null(sc$log)) {
dec <- floor(log(sc$min, sc$log)):ceiling(log(sc$max, sc$log))
pos <- as.vector(outer(2:(sc$log - 1), sc$log^dec))
return(pos[pos > sc$min & pos < sc$max])
}
step <- p$minor %||% (sc$major / 5)
pos <- seq(sc$min, sc$max, by = step)
pos[abs((pos - sc$min) %% sc$major) > 1e-9 * sc$major & abs(((pos - sc$min) %% sc$major) - sc$major) > 1e-9 * sc$major]
}
x_cross <- cross_val(y1, px)
y_cross <- if (is_xy) cross_val(xa, py) else lim(xa)[1]
x_line_gp <- plot_axis_gp_line(px)
y_line_gp <- plot_axis_gp_line(py)
# category / x axis; a deleted axis is not drawn at all
x_label_pos <- px$label_pos %||% "nextTo"
x_major <- tick_ends(px$major_tick %||% "cross", 4, -1)
if (isTRUE(px$delete)) {
} else if (horizontal) {
grid::grid.lines(x = grid::unit(c(x_cross, x_cross), "native"), y = grid::unit(c(0, 1), "npc"), gp = x_line_gp)
lab_x <- switch(x_label_pos, low = grid::unit(0, "npc"), high = grid::unit(1, "npc"), grid::unit(x_cross, "native"))
for (i in seq_along(x_lab_at)) {
grid::grid.text(x_labels[i], x = lab_x - grid::unit(6, "points"), y = grid::unit(x_lab_at[i], "native"),
just = c("right", "center"), rot = rot_x, gp = x_gp)
}
if (!is.null(x_major)) {
tks <- if (is_xy) tr(x_ticks, xa) else if (mid_cat) x_ticks else 0:length(cats)
if (!is_xy && !is.null(px$tick_mark_skip)) tks <- tks[seq(1, length(tks), by = px$tick_mark_skip)]
for (t in tks) grid::grid.lines(x = grid::unit(x_cross, "native") + grid::unit(x_major, "points"), y = grid::unit(c(t, t), "native"), gp = x_line_gp)
}
if (is_xy) {
x_minor <- tick_ends(px$minor_tick %||% "cross", 2, -1)
if (!is.null(x_minor)) for (t in tr(minor_positions(xa, px), xa)) grid::grid.lines(x = grid::unit(x_cross, "native") + grid::unit(x_minor, "points"), y = grid::unit(c(t, t), "native"), gp = x_line_gp)
} else {
# minor ticks of a category axis sit at the category centers
x_minor <- tick_ends(px$minor_tick %||% "cross", 3, -1)
if (!is.null(x_minor)) for (t in seq_along(cats) - 0.5) grid::grid.lines(x = grid::unit(x_cross, "native") + grid::unit(x_minor, "points"), y = grid::unit(c(t, t), "native"), gp = x_line_gp)
}
} else {
grid::grid.lines(x = grid::unit(c(0, 1), "npc"), y = grid::unit(c(x_cross, x_cross), "native"), gp = x_line_gp)
lab_y <- switch(x_label_pos, low = grid::unit(0, "npc"), high = grid::unit(1, "npc"), grid::unit(x_cross, "native"))
just <- if (rot_x > 0) c("right", "top") else if (rot_x < 0) c("left", "top") else c("center", "top")
gap <- if (rot_x != 0) 10 else 6
for (i in seq_along(x_lab_at)) {
grid::grid.text(x_labels[i], x = grid::unit(tr(x_lab_at[i], xa), "native"), y = lab_y - grid::unit(gap, "points"),
just = just, rot = rot_x, gp = x_gp)
}
if (!is.null(x_major)) {
tks <- if (is_xy) tr(x_ticks, xa) else if (is_date || mid_cat) x_ticks else 0:n_slots
if (!is_xy && !is.null(px$tick_mark_skip)) tks <- tks[seq(1, length(tks), by = px$tick_mark_skip)]
for (t in tks) grid::grid.lines(x = grid::unit(c(t, t), "native"), y = grid::unit(x_cross, "native") + grid::unit(x_major, "points"), gp = x_line_gp)
}
if (is_xy) {
x_minor <- tick_ends(px$minor_tick %||% "cross", 2, -1)
if (!is.null(x_minor)) for (t in tr(minor_positions(xa, px), xa)) grid::grid.lines(x = grid::unit(c(t, t), "native"), y = grid::unit(x_cross, "native") + grid::unit(x_minor, "points"), gp = x_line_gp)
} else if (!is_date) {
# minor ticks of a category axis sit at the category centers
x_minor <- tick_ends(px$minor_tick %||% "cross", 3, -1)
if (!is.null(x_minor) && !mid_cat) {
for (t in seq_len(n_slots) - 0.5) {
grid::grid.lines(x = grid::unit(c(t, t), "native"), y = grid::unit(x_cross, "native") + grid::unit(x_minor, "points"), gp = x_line_gp)
}
}
}
if (is_date && !is.null(px$minor)) {
x_minor <- tick_ends(px$minor_tick %||% "cross", 2, -1)
minor_step <- px$minor * unit_in_base(px$minor_time)
if (!is.null(x_minor)) for (t in seq(0, date_span, by = minor_step)) grid::grid.lines(x = grid::unit(c(t, t), "native"), y = grid::unit(x_cross, "native") + grid::unit(x_minor, "points"), gp = x_line_gp)
}
# outer levels of multi-level categories: one row per level below the
# inner labels, each group centered over its span with separator lines
if (outer_levels > 0) {
lv <- series[[1]]$cat_levels
row_h <- text_h(x_gp) + 6
for (level in rev(seq_len(outer_levels))) {
depth <- outer_levels - level + 1
labels <- lv[[level]]
labels[is.na(labels)] <- ""
starts <- which(nzchar(labels))
if (!length(starts) || starts[1] != 1) starts <- c(1, starts)
ends <- c(starts[-1] - 1, nrow(lv))
y_row <- lab_y - grid::unit(6 + depth * row_h, "points")
for (g in seq_along(starts)) {
grid::grid.text(labels[starts[g]], x = grid::unit((starts[g] - 1 + ends[g]) / 2, "native"),
y = y_row, just = c("center", "top"), gp = x_gp)
}
for (b in c(starts - 1, nrow(lv))) {
grid::grid.lines(x = grid::unit(c(b, b), "native"), y = lab_y - grid::unit(c(0, 6 + depth * row_h + text_h(x_gp)), "points"), gp = x_line_gp)
}
}
}
}
# value axes. `side` is where the axis sits; for the primary axis of a
# scatter chart the line is at the crossing value instead of the edge.
draw_val_axis <- function(sc, p, ticks, labels, gp, side, line_gp, at_cross = FALSE) {
if (isTRUE(p$delete)) return(invisible())
outward <- if (side %in% c("right", "top")) 1 else -1
major <- tick_ends(p$major_tick %||% "cross", 4, outward)
minor <- tick_ends(p$minor_tick %||% "cross", 2, outward)
minors <- tr(minor_positions(sc, p), sc)
if (horizontal) {
yy <- if (side == "top") grid::unit(1, "npc") else grid::unit(0, "npc")
grid::grid.lines(x = grid::unit(c(0, 1), "npc"), y = yy, gp = line_gp)
for (i in seq_along(ticks)) {
t <- tr(ticks[i], sc)
if (!is.null(major)) grid::grid.lines(x = grid::unit(c(t, t), "native"), y = yy + grid::unit(major, "points"), gp = line_gp)
grid::grid.text(labels[i], x = grid::unit(t, "native"), y = yy + grid::unit(6 * outward, "points"),
just = c("center", if (outward > 0) "bottom" else "top"), gp = gp)
}
if (!is.null(minor)) for (t in minors) grid::grid.lines(x = grid::unit(c(t, t), "native"), y = yy + grid::unit(minor, "points"), gp = line_gp)
} else {
xx <- if (at_cross) grid::unit(y_cross, "native") else if (side == "right") grid::unit(1, "npc") else grid::unit(0, "npc")
grid::grid.lines(x = xx, y = grid::unit(c(0, 1), "npc"), gp = line_gp)
for (i in seq_along(ticks)) {
t <- tr(ticks[i], sc)
if (!is.null(major)) grid::grid.lines(x = xx + grid::unit(major, "points"), y = grid::unit(c(t, t), "native"), gp = line_gp)
grid::grid.text(labels[i], x = xx + grid::unit(6 * outward, "points"), y = grid::unit(t, "native"),
just = c(if (outward > 0) "left" else "right", "center"), gp = gp)
}
if (!is.null(minor)) for (t in minors) grid::grid.lines(x = xx + grid::unit(minor, "points"), y = grid::unit(c(t, t), "native"), gp = line_gp)
}
}
draw_val_axis(y1, py, y_ticks, y_labels, y_gp, if (horizontal) "bottom" else y_side, y_line_gp, at_cross = is_xy)
if (!is.null(y2)) {
push_scale(list(sec_type = "y"), clip = FALSE)
draw_val_axis(y2, py2, y2_ticks, y2_labels, y2_gp, if (horizontal) "top" else "right", plot_axis_gp_line(py2))
grid::upViewport()
}
if (!is.null(x2) && !horizontal && !isTRUE(px2$delete)) {
push_scale(list(sec_type = "x"), clip = FALSE)
x2_line_gp <- plot_axis_gp_line(px2)
x2_major <- tick_ends(px2$major_tick %||% "cross", 4, 1)
grid::grid.lines(x = grid::unit(c(0, 1), "npc"), y = grid::unit(1, "npc"), gp = x2_line_gp)
for (i in seq_along(x2_ticks)) {
t <- tr(x2_ticks[i], x2)
if (!is.null(x2_major)) grid::grid.lines(x = grid::unit(c(t, t), "native"), y = grid::unit(1, "npc") + grid::unit(x2_major, "points"), gp = x2_line_gp)
grid::grid.text(x2_labels[i], x = grid::unit(t, "native"), y = grid::unit(1, "npc") + grid::unit(6, "points"), just = c("center", "bottom"), gp = x2_gp)
}
x2_minor <- tick_ends(px2$minor_tick %||% "cross", 2, 1)
if (!is.null(x2_minor) && is_xy) for (t in tr(minor_positions(x2, px2), x2)) grid::grid.lines(x = grid::unit(c(t, t), "native"), y = grid::unit(1, "npc") + grid::unit(x2_minor, "points"), gp = x2_line_gp)
grid::upViewport()
}
grid::upViewport()
if (data_table) {
grid::pushViewport(grid::viewport(layout.pos.row = 3, layout.pos.col = 1:2))
tot_w <- grid::convertWidth(grid::unit(1, "npc"), "points", valueOnly = TRUE)
tot_h <- grid::convertHeight(grid::unit(1, "npc"), "points", valueOnly = TRUE)
plot_w <- tot_w - left_w
x_at <- function(t) grid::unit(left_w + t / n_slots * plot_w, "points")
y_top <- tot_h - dt_hdr_h
y_bottom <- y_top - length(series) * dt_row_h
x_left <- grid::unit(left_w - dt_name_w, "points")
tgp <- grid::gpar(col = "#D9D9D9", lwd = 0.75 * 96 / 72)
for (j in 0:length(series)) {
grid::grid.lines(grid::unit.c(x_left, x_at(n_slots)), grid::unit(rep(y_top - j * dt_row_h, 2), "points"), gp = tgp)
}
for (t in 0:n_slots) grid::grid.lines(grid::unit.c(x_at(t), x_at(t)), grid::unit(c(tot_h, y_bottom), "points"), gp = tgp)
grid::grid.lines(grid::unit.c(x_left, x_left), grid::unit(c(y_top, y_bottom), "points"), gp = tgp)
for (j in seq_along(series)) {
s <- series[[j]]
yc <- y_top - (j - 0.5) * dt_row_h
grid::grid.rect(x = x_left + grid::unit(8, "points"), y = grid::unit(yc, "points"),
width = grid::unit(6, "points"), height = grid::unit(6, "points"),
gp = grid::gpar(fill = s$auto_col, col = NA))
grid::grid.text(s$label_text, x = x_left + grid::unit(15, "points"), y = grid::unit(yc, "points"), just = c("left", "center"), gp = x_gp)
for (i in seq_along(s$values)) {
if (is.na(s$values[i])) next
grid::grid.text(plot_format(s$values[i]), x = x_at(i - 0.5), y = grid::unit(yc, "points"), gp = x_gp)
}
}
grid::upViewport()
}
# ---- axis titles ----
if (!is.null(chart$x_title$text)) {
if (horizontal) {
grid::pushViewport(grid::viewport(layout.pos.row = 2, layout.pos.col = 1))
grid::grid.text(plot_title_text(chart$x_title), x = grid::unit(xt_h / 2, "points"), rot = 90, gp = plot_gpar_text(chart$x_title$style, 10, "#000000"))
} else {
grid::pushViewport(grid::viewport(layout.pos.row = 3, layout.pos.col = 2))
grid::grid.text(plot_title_text(chart$x_title), y = grid::unit(xt_h / 2, "points"), gp = plot_gpar_text(chart$x_title$style, 10, "#000000"))
}
grid::upViewport()
}
if (!is.null(chart$y_title$text)) {
if (horizontal) {
grid::pushViewport(grid::viewport(layout.pos.row = 3, layout.pos.col = 2))
grid::grid.text(plot_title_text(chart$y_title), y = grid::unit(yt_w / 2, "points"), gp = plot_gpar_text(chart$y_title$style, 10, "#000000"))
} else {
grid::pushViewport(grid::viewport(layout.pos.row = 2, layout.pos.col = 1))
grid::grid.text(plot_title_text(chart$y_title), x = grid::unit(yt_w / 2, "points"), rot = 90, gp = plot_gpar_text(chart$y_title$style, 10, "#000000"))
}
grid::upViewport()
}
if (!is.null(x2) && !is.null(chart$x2_title$text) && !horizontal) {
grid::pushViewport(grid::viewport(layout.pos.row = 1, layout.pos.col = 2))
grid::grid.text(plot_title_text(chart$x2_title), y = grid::unit(1, "npc") - grid::unit(x2t_h / 2, "points"), gp = plot_gpar_text(chart$x2_title$style, 10, "#000000"))
grid::upViewport()
}
if (!is.null(chart$y2_title$text) && !horizontal) {
grid::pushViewport(grid::viewport(layout.pos.row = 2, layout.pos.col = 3))
grid::grid.text(plot_title_text(chart$y2_title), x = grid::unit(1, "npc") - grid::unit(y2t_w / 2, "points"), rot = 90, gp = plot_gpar_text(chart$y2_title$style, 10, "#000000"))
grid::upViewport()
}
grid::upViewport()
invisible()
}
# ---------------------------------------------------------------------------
# Pie and doughnut
# ---------------------------------------------------------------------------
plot_pie <- function(chart, series) {
pal <- chart$palette
if (length(series[[1]]$line$color) > 1) pal <- series[[1]]$line$color
start <- (chart$first_slice_ang %||% 0) * pi / 180
hole <- if (chart$type == "doughnutChart") (chart$hole_size %||% 75) / 100 else 0
expl <- (chart$expansion %||% 0) / 100
lp <- chart$label_params
# a doughnut draws every series as a ring, the first one innermost; a
# pie only its first series
rings <- if (hole > 0) series else series[1]
n_ring <- length(rings)
grid::pushViewport(grid::viewport(width = grid::unit(1, "snpc"), height = grid::unit(1, "snpc")))
r_max <- 0.42 / (1 + expl)
ring_w <- r_max * (1 - hole) / n_ring
for (k in seq_len(n_ring)) {
s <- rings[[k]]
v <- abs(s$values)
v[is.na(v)] <- 0
n <- length(v)
cols <- vapply(seq_len(n), function(i) plot_auto_color(i, pal), character(1))
for (p in s$points) if (!is.null(p$color) && p$idx + 1 <= n) cols[p$idx + 1] <- plot_color(p$color, cols[p$idx + 1])
total <- sum(v)
r_in <- r_max * hole + (k - 1) * ring_w
r_out <- r_in + ring_w
slp <- s$label_params %||% lp
a0 <- start
for (i in seq_len(n)) {
if (total == 0) break
sweep <- 2 * pi * v[i] / total
a1 <- a0 + sweep
ang <- seq(a0, a1, length.out = max(2, ceiling(sweep * 60)))
mid <- (a0 + a1) / 2
cx <- 0.5 + expl * r_max * sin(mid)
cy <- 0.5 + expl * r_max * cos(mid)
px <- c(cx + r_out * sin(ang), cx + r_in * sin(rev(ang)))
py <- c(cy + r_out * cos(ang), cy + r_in * cos(rev(ang)))
grid::grid.polygon(px, py, gp = grid::gpar(fill = cols[i], col = "#FFFFFF", lwd = 1))
if (plot_labels_on(slp) && v[i] > 0) {
# labels sit in the middle of a ring, in the outer part of a pie
# and outside it for the outer end position
# pie labels sit towards the rim ("bestFit", written for the outer
# end as well) or at half the radius for the center position
pos <- slp$pos %||% "bestFit"
rr <- if (hole > 0) (r_in + r_out) / 2
else if (pos == "ctr") r_out * 0.5
else if (pos %in% c("outEnd", "bestFit", "inEnd")) r_out * 0.72
else r_out * 0.65
just <- c("center", "center")
grid::grid.text(plot_label_text(slp, s$cats[i], s$values[i], pct = v[i] / total, name = s$label_text, sep = "\n"),
x = cx + rr * sin(mid), y = cy + rr * cos(mid), just = just, gp = plot_gpar_text(slp$style, 9, "#000000"))
}
a0 <- a1
}
}
grid::upViewport()
invisible()
}
# ---------------------------------------------------------------------------
# Pie of pie, bar of pie
# ---------------------------------------------------------------------------
# Points moved to the second plot, as 1-based indices
plot_of_pie_split <- function(chart, v) {
n <- length(v)
total <- sum(v)
pos <- chart$split_pos
switch(chart$split_type %||% "auto",
pos = if (is.null(pos)) integer() else seq_len(n)[seq_len(n) > n - pos],
val = which(v < (pos %||% 0)),
percent = which(v / total * 100 < (pos %||% 0)),
cust = intersect(pos + 1, seq_len(n)),
# "auto": the last third of the points, rounded up
seq_len(n)[seq_len(n) > n - ceiling(n / 3)]
)
}
plot_of_pie <- function(chart, series) {
s <- series[[1]]
v <- abs(s$values)
v[is.na(v)] <- 0
n <- length(v)
pal <- chart$palette
if (length(s$line$color) > 1) pal <- s$line$color
cols <- vapply(seq_len(n), function(i) plot_auto_color(i, pal), character(1))
total <- sum(v)
lp <- chart$label_params
sec <- plot_of_pie_split(chart, v)
main <- setdiff(seq_len(n), sec)
is_bar <- identical(chart$of_pie_type, "bar")
size2 <- (chart$second_pie_size %||% 75) / 100
# the gap is three quarters of the second plot's width per 100 percent
w2 <- if (is_bar) size2 / 2 else size2
gap <- (s$gap_width %||% 100) / 100 * 0.75 * w2
w_pt <- grid::convertWidth(grid::unit(1, "npc"), "points", valueOnly = TRUE)
h_pt <- grid::convertHeight(grid::unit(1, "npc"), "points", valueOnly = TRUE)
label_gp <- plot_gpar_text(lp$style, 9, "#000000")
# room for the labels next to a bar
bar_lab_w <- 0
if (is_bar && plot_labels_on(lp)) {
widths <- vapply(sec, function(i) {
txt <- plot_label_text(lp, s$cats[i], s$values[i], pct = v[i] / total, name = s$label_text)
grid::convertWidth(grid::grobWidth(grid::textGrob(txt, gp = label_gp)), "points", valueOnly = TRUE)
}, numeric(1))
bar_lab_w <- 8 + max(widths)
}
# first pie of diameter d, the second plot d * size2 (a bar is half as
# wide as high), the group centered
d <- min(h_pt, (w_pt - bar_lab_w) / (1 + gap + w2))
d2 <- d * size2
span <- d * (1 + gap + w2) + bar_lab_w
x1 <- (w_pt - span) / 2 + d / 2
x2 <- x1 + d / 2 + d * gap + d * w2 / 2
cy <- h_pt / 2
slice <- function(cx, r, a0, a1, col) {
ang <- seq(a0, a1, length.out = max(2, ceiling((a1 - a0) * 60)))
grid::grid.polygon(grid::unit(c(cx, cx + r * sin(ang)), "points"), grid::unit(c(cy, cy + r * cos(ang)), "points"),
gp = grid::gpar(fill = col, col = "#FFFFFF", lwd = 1))
}
label <- function(i, x, y, just = c("center", "center")) {
if (!plot_labels_on(lp) || v[i] == 0) return()
grid::grid.text(plot_label_text(lp, s$cats[i], s$values[i], pct = v[i] / total, name = s$label_text, sep = if (is_bar) "; " else "\n"),
x = grid::unit(x, "points"), y = grid::unit(y, "points"), just = just, gp = label_gp)
}
# first pie: the slice holding the rest faces the second plot, centered
# on its right; the remaining points follow clockwise
other <- sum(v[sec])
r <- d / 2
other_sweep <- 2 * pi * other / total
a0 <- pi / 2 + other_sweep / 2 + (chart$first_slice_ang %||% 0) * pi / 180
for (k in main) {
a1 <- a0 + 2 * pi * v[k] / total
slice(x1, r, a0, a1, cols[k])
mid <- (a0 + a1) / 2
label(k, x1 + 0.65 * r * sin(mid), cy + 0.65 * r * cos(mid))
a0 <- a1
}
other_arc <- c(a0, a0 + other_sweep)
if (other > 0) {
slice(x1, r, other_arc[1], other_arc[2], plot_auto_color(n + 1, pal))
if (plot_labels_on(lp)) {
mid <- mean(other_arc)
grid::grid.text(plot_label_text(lp, "Other", other, pct = other / total, name = s$label_text, sep = if (is_bar) "; " else "\n"),
x = grid::unit(x1 + 0.65 * r * sin(mid), "points"), y = grid::unit(cy + 0.65 * r * cos(mid), "points"), gp = label_gp)
}
}
# second plot: a bar stacks the points from the top down, a pie starts
# them at the right
if (is_bar) {
bw <- d2 / 2
y0 <- cy + d2 / 2
for (i in sec) {
h <- if (other > 0) d2 * v[i] / other else 0
grid::grid.rect(x = grid::unit(x2 - bw / 2, "points"), y = grid::unit(y0 - h, "points"),
width = grid::unit(bw, "points"), height = grid::unit(h, "points"),
just = c("left", "bottom"), gp = grid::gpar(fill = cols[i], col = "#FFFFFF", lwd = 1))
label(i, x2 + bw / 2 + 4, y0 - h / 2, just = c("left", "center"))
y0 <- y0 - h
}
top <- c(x2 - bw / 2, cy + d2 / 2)
bottom <- c(x2 - bw / 2, cy - d2 / 2)
} else {
r2 <- d2 / 2
b0 <- pi / 2
for (i in sec) {
b1 <- b0 + if (other > 0) 2 * pi * v[i] / other else 0
slice(x2, r2, b0, b1, cols[i])
mid <- (b0 + b1) / 2
label(i, x2 + 0.65 * r2 * sin(mid), cy + 0.65 * r2 * cos(mid))
b0 <- b1
}
top <- c(x2, cy + r2)
bottom <- c(x2, cy - r2)
}
# series lines from the edges of the rest slice to the second plot
if (other > 0) {
lgp <- grid::gpar(col = "#404040", lwd = 0.75 * 96 / 72)
grid::grid.lines(grid::unit(c(x1 + r * sin(other_arc[1]), top[1]), "points"),
grid::unit(c(cy + r * cos(other_arc[1]), top[2]), "points"), gp = lgp)
grid::grid.lines(grid::unit(c(x1 + r * sin(other_arc[2]), bottom[1]), "points"),
grid::unit(c(cy + r * cos(other_arc[2]), bottom[2]), "points"), gp = lgp)
}
invisible()
}
# ---------------------------------------------------------------------------
# Radar
# ---------------------------------------------------------------------------
plot_radar <- function(chart, series) {
cats <- series[[1]]$cats
n <- length(cats)
all_v <- unlist(lapply(series, function(s) s$values))
sc <- plot_scale(min(0, all_v, na.rm = TRUE), max(all_v, na.rm = TRUE), chart$axis_params$y, pad = FALSE)
ticks <- plot_ticks(sc)
py <- chart$axis_params$y
px <- chart$axis_params$x
gp_lab <- plot_gpar_text(px, 10, "#000000")
grid::pushViewport(grid::viewport(width = grid::unit(1, "snpc"), height = grid::unit(1, "snpc")))
r_max <- 0.38
ang <- 2 * pi * (seq_len(n) - 1) / n
rad <- function(val) r_max * (val - sc$min) / (sc$max - sc$min)
ggp <- plot_grid_gp(py)
for (t in ticks) {
grid::grid.polygon(0.5 + rad(t) * sin(ang), 0.5 + rad(t) * cos(ang), gp = grid::gpar(col = ggp$col, fill = NA, lwd = ggp$lwd))
}
# the value axis runs along every spoke: black, with crossing tick marks
# at the major and minor units (Excel's default for missing tick marks)
spoke_gp <- plot_axis_gp_line(py)
major_len <- 0.008
minor_len <- 0.005
minor_ticks <- seq(sc$min, sc$max, by = py$minor %||% (sc$major / 5))
for (k in seq_len(n)) {
sx <- sin(ang[k])
sy <- cos(ang[k])
grid::grid.lines(c(0.5, 0.5 + r_max * sx), c(0.5, 0.5 + r_max * sy), gp = spoke_gp)
if (!identical(py$major_tick, "none")) {
for (t in ticks) grid::grid.lines(0.5 + rad(t) * sx + c(-1, 1) * major_len * sy, 0.5 + rad(t) * sy - c(-1, 1) * major_len * sx, gp = spoke_gp)
}
if (!identical(py$minor_tick, "none")) {
for (t in minor_ticks) grid::grid.lines(0.5 + rad(t) * sx + c(-1, 1) * minor_len * sy, 0.5 + rad(t) * sy - c(-1, 1) * minor_len * sx, gp = spoke_gp)
}
grid::grid.text(plot_format(cats[k], px$format), x = 0.5 + (r_max + 0.05) * sx, y = 0.5 + (r_max + 0.05) * sy, gp = gp_lab)
}
filled <- isTRUE(series[[1]]$filled)
if (!identical(py$label_pos, "none")) {
for (t in ticks) grid::grid.text(plot_format(t, py$format), x = 0.5 - 0.01, y = 0.5 + rad(t), just = c("right", "center"), gp = plot_gpar_text(py, 10, "#000000"))
}
for (s in series) {
col <- s$auto_col
v <- s$values
v[is.na(v)] <- sc$min
x <- 0.5 + rad(v) * sin(ang[seq_along(v)])
y <- 0.5 + rad(v) * cos(ang[seq_along(v)])
if (filled) {
grid::grid.polygon(x, y, gp = grid::gpar(fill = col, col = col, lwd = 1.5))
} else {
grid::grid.polygon(x, y, gp = grid::gpar(fill = NA, col = col, lwd = 2.25 * 96 / 72, lty = plot_lty(s$line$type)))
plot_draw_markers(grid::unit(x, "npc"), grid::unit(y, "npc"), s$marker, col)
}
}
grid::upViewport()
invisible()
}
# ---------------------------------------------------------------------------
# plot.Chart()
# ---------------------------------------------------------------------------
#' Plot a chart
#'
#' @description
#' Draws a `Chart` or `ChartEx` object on the current graphics device with
#' grid, approximating what a spreadsheet application shows for it.
#'
#' Supported are bar/column (clustered, stacked, percent stacked, horizontal),
#' line, area, scatter, bubble, pie, doughnut, pie of pie, bar of pie, radar
#' and stock charts with titles, primary and secondary axes, gridlines,
#' legend, markers, line styles, data labels, trendlines, error bars,
#' high-low lines, drop lines and up-down bars; the 3D column, bar, line,
#' area and pie types and surface charts (contour and 3D); and the extended
#' types waterfall, box-and-whisker, histogram, Pareto, funnel, treemap and
#' sunburst. Region maps are not drawn.
#'
#' @details
#' The values come from the series caches (present when a series was added
#' from `wb_data()`) or are read from `wb`. Series added with plain range
#' strings and all `ChartEx` objects need `wb`.
#'
#' Axis scaling follows the rules for automatic axes; fonts, spacing and
#' the exact placement of labels differ from the original. Number formats are
#' approximated for common codes (`0`, `0.00`, `#,##0`, `0%`, date formats).
#'
#' @param x A `Chart` or `ChartEx` object.
#' @param wb Optional `wbWorkbook` the series reference.
#' @param newpage Call `grid::grid.newpage()` first. Default `TRUE`.
#' @param ... Ignored.
#' @return `x`, invisibly.
#' @examples
#' wb <- openxlsx2::wb_workbook()$add_worksheet("Data")$add_data(x = data.frame(
#' Month = month.abb[1:6], Sales = c(120, 135, 128, 160, 175, 190)
#' ))
#' chart <- ec("line")$set_chart_title("Sales")$
#' add_series(name = Sales, data = openxlsx2::wb_data(wb), label = Month, marker = "circle")
#' plot(chart)
#' @export
plot.Chart <- function(x, wb = NULL, newpage = TRUE, ...) {
chart <- x
if (!inherits(chart, "Chart")) {
stop("Only standard charts (class 'Chart') can be plotted.", call. = FALSE)
}
if (length(chart$series_data) == 0) stop("The chart has no series.", call. = FALSE)
plot_set_theme(wb)
plot_state$text_style <- chart$text_style
on.exit({
plot_set_theme(NULL)
plot_state$text_style <- list()
}, add = TRUE)
types <- unique(vapply(chart$series_data, function(s) s$type, character(1)))
bad <- setdiff(types, ENCHARTER_PLOT_TYPES)
if (length(bad)) {
stop("Chart type not supported by plot(): ", paste(bad, collapse = ", "), call. = FALSE)
}
if (!is.null(wb) && !inherits(wb, "wbWorkbook")) stop("'wb' must be a wbWorkbook object.", call. = FALSE)
series <- plot_collect(chart, wb)
# a series without a color of its own takes the automatic one
for (j in seq_along(series)) {
series[[j]]$auto_col <- plot_color(series[[j]]$line$color, plot_auto_color(j, chart$palette))
}
if (isTRUE(newpage)) grid::grid.newpage()
depth0 <- length(grid::current.vpPath())
on.exit({
extra <- length(grid::current.vpPath()) - depth0
if (extra > 0) grid::upViewport(extra)
}, add = TRUE)
cs <- chart$chart_style
grid::grid.rect(gp = grid::gpar(fill = plot_color(cs$fill, "#FFFFFF"), col = plot_color(cs$line, "#D9D9D9"),
lwd = (cs$line_width %||% 1) * 96 / 72))
chart_w <- grid::convertWidth(grid::unit(1, "npc"), "points", valueOnly = TRUE) - 16
title_h <- plot_title_height(chart$chart_title, 14, chart_w)
plot_state$chart_size <- c(chart_w + 16, grid::convertHeight(grid::unit(1, "npc"), "points", valueOnly = TRUE))
draw_body <- function() {
if (types[1] %in% c("pieChart", "doughnutChart")) plot_pie(chart, series)
else if (types[1] == "ofPieChart") plot_of_pie(chart, series)
else if (types[1] == "pie3DChart") plot_3d_pie(chart, series)
else if (types[1] %in% c("bar3DChart", "line3DChart", "area3DChart")) plot_3d_cartesian(chart, series)
else if (types[1] %in% c("surfaceChart", "surface3DChart")) plot_surface(chart, series)
else if (types[1] == "radarChart") plot_radar(chart, series)
else plot_cartesian(chart, series)
}
legend <- NULL
l_pos <- chart$legend_params$pos %||% "r"
if (l_pos != "none") {
entries <- if (types[1] %in% c("surfaceChart", "surface3DChart")) {
# one entry per value band, the highest first
bands <- plot_surface_bands(chart, unlist(lapply(series, function(s) s$values)), types[1] == "surfaceChart")
fmt <- chart$axis_params$y$format
lapply(rev(seq_along(bands$cols)), function(i) {
list(kind = "rect", label = paste0(plot_format(bands$breaks[i], fmt), "-", plot_format(bands$breaks[i + 1], fmt)), col = bands$cols[i])
})
} else if (types[1] %in% c("pieChart", "doughnutChart", "ofPieChart", "pie3DChart")) {
s <- series[[1]]
pal <- if (length(s$line$color) > 1) s$line$color else chart$palette
e <- lapply(seq_along(s$values), function(i) {
list(kind = "rect", label = plot_format(s$cats[i]), col = plot_auto_color(i, pal))
})
e
} else {
entries <- list()
for (s in series) {
col <- s$auto_col
if (s$type %in% c("lineChart", "scatterChart", "stockChart") || (s$type == "radarChart" && !isTRUE(series[[1]]$filled))) {
m <- s$marker
if (s$type == "scatterChart" && (is.null(m$symbol) || m$symbol == "none")) m$symbol <- "circle"
entries[[length(entries) + 1]] <- list(
kind = "line", label = s$label_text, col = if (isFALSE(s$line$show)) NA else col,
lwd = (s$line$width %||% 1) * 96 / 72, lty = plot_lty(s$line$type),
pch = plot_pch(m$symbol), cex = m$size %||% 5,
mcol = plot_color(m$line$color, col), mfill = plot_color(m$fill, col))
} else {
entries[[length(entries) + 1]] <- list(kind = "rect", label = s$label_text, col = col)
}
if (is.list(s$trendline) && !types[1] %in% c(ENCHARTER_PLOT_PIES, "radarChart")) {
entries[[length(entries) + 1]] <- list(
kind = "line", label = plot_trend_name(s$trendline, s$label_text),
col = plot_color(s$trendline$color, col), lwd = 1.5 * 96 / 72, lty = "solid", pch = NA_integer_)
}
}
entries
}
if (types[1] == "bubbleChart") {
s <- series[[1]]
entries <- lapply(seq_along(s$values), function(i) {
list(kind = "rect", label = plot_format(s$cats[i]), col = plot_auto_color(i, chart$palette))
})
}
stacked <- all(vapply(series, function(s) {
s$grouping %in% c("stacked", "percentStacked") && s$type %in% c("barChart", "areaChart", "lineChart")
}, logical(1)))
if (stacked && l_pos %in% c("l", "r", "tr")) entries <- rev(entries)
legend <- plot_legend(entries, chart$legend_params, chart$legend_params$style, max_w = chart_w)
}
pad <- 8
# an overlaid legend takes no room from the plot area
overlay <- !is.null(legend) && (identical(chart$legend_params$overlay, "1") || isTRUE(chart$legend_params$overlay))
lw <- if (!is.null(legend) && !overlay && legend$pos %in% c("l", "r")) legend$size[["w"]] + pad else 0
lh <- if (!is.null(legend) && !overlay && legend$pos %in% c("t", "b")) legend$size[["h"]] + pad else 0
layout <- grid::grid.layout(
5, 3,
widths = grid::unit(c(pad + if (identical(legend$pos, "l")) lw else 0, 1, pad + if (identical(legend$pos, "r")) lw else 0), c("points", "null", "points")),
heights = grid::unit(c(pad + title_h, if (identical(legend$pos, "t")) lh else 0, 1, if (identical(legend$pos, "b")) lh else 0, pad), c("points", "points", "null", "points", "points"))
)
grid::pushViewport(grid::viewport(layout = layout))
if (title_h > 0) {
grid::pushViewport(grid::viewport(layout.pos.row = 1, layout.pos.col = 1:3))
grid::pushViewport(grid::viewport(y = grid::unit(0, "npc"), height = grid::unit(title_h, "points"), just = "bottom"))
plot_draw_title(chart$chart_title, 14, chart_w)
grid::upViewport(2)
}
if (!is.null(chart$plot_layout) && !types[1] %in% c("pieChart", "doughnutChart", "ofPieChart", "pie3DChart", "radarChart")) {
# the plot area sits at its fixed position within the chart
ml <- chart$plot_layout
grid::upViewport()
grid::pushViewport(grid::viewport(x = ml$x, y = 1 - ml$y - ml$h, width = ml$w, height = ml$h, just = c("left", "bottom")))
draw_body()
grid::upViewport()
grid::pushViewport(grid::viewport(layout = layout))
} else {
grid::pushViewport(grid::viewport(layout.pos.row = 3, layout.pos.col = 2))
grid::pushViewport(grid::viewport(width = grid::unit(1, "npc") - grid::unit(2 * pad, "points"),
height = grid::unit(1, "npc") - grid::unit(pad, "points")))
draw_body()
grid::upViewport(2)
}
if (!is.null(legend)) {
row <- if (overlay) 3 else switch(legend$pos, t = 2, b = 4, 3)
col <- if (overlay) 2 else switch(legend$pos, l = 1, r = 3, 1:3)
grid::pushViewport(grid::viewport(layout.pos.row = row, layout.pos.col = col))
grid::pushViewport(grid::viewport(
x = if (legend$pos == "r") grid::unit(1, "npc") - grid::unit(pad, "points")
else if (legend$pos == "l") grid::unit(pad, "points")
else grid::unit(0.5, "npc"),
y = if (overlay && legend$pos == "t") grid::unit(1, "npc") - grid::unit(pad, "points")
else if (overlay && legend$pos == "b") grid::unit(pad, "points")
else grid::unit(0.5, "npc"),
just = c(
if (legend$pos == "r") "right" else if (legend$pos == "l") "left" else "center",
if (overlay && legend$pos == "t") "top" else if (overlay && legend$pos == "b") "bottom" else "center"
),
width = grid::unit(legend$size[["w"]], "points"), height = grid::unit(legend$size[["h"]], "points")))
legend$draw()
grid::upViewport(2)
}
grid::upViewport()
invisible(chart)
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.