|
| 1 | +# ============================================================================= |
| 2 | +# Charts |
| 3 | +# ============================================================================= |
| 4 | +# |
| 5 | +# A chart is a drawing placed on a worksheet, so its placement arguments are |
| 6 | +# xl_image()'s: lxw_chart_options is lxw_image_options minus url, tip and |
| 7 | +# cell_format, with the same field names. Keeping the R names identical is not |
| 8 | +# cosmetic -- the API-consistency gate enforces it. |
| 9 | +# |
| 10 | +# A series names two ranges: the values, and optionally the categories to plot |
| 11 | +# them against. chart_series_set_values() and _set_categories() take a sheet |
| 12 | +# name plus a 0-based quad rather than an A1 string, so the shared range |
| 13 | +# resolver's output plugs straight in. A series may point at a different sheet |
| 14 | +# from the one the chart sits on, which is why ranges carry an optional `sheet` |
| 15 | +# and are resolved once the whole workbook is known. |
| 16 | +# |
| 17 | +# Charts themselves produce a drawing part, so they do not desync |
| 18 | +# libxlsxwriter's drawing-id counter -- but they are *victims* of it in the |
| 19 | +# same way a floating image is, which .check_drawing_order() accounts for. |
| 20 | +# Verified against libxlsxwriter 1.2.4 before any of this was written. |
| 21 | +# ----------------------------------------------------------------------------- |
| 22 | + |
| 23 | +# The chart types, named as Excel names them rather than as the enum spells it. |
| 24 | +.LXW_CHART_TYPE <- c( |
| 25 | + area = 1L, area_stacked = 2L, area_stacked_percent = 3L, |
| 26 | + bar = 4L, bar_stacked = 5L, bar_stacked_percent = 6L, |
| 27 | + column = 7L, column_stacked = 8L, column_stacked_percent = 9L, |
| 28 | + doughnut = 10L, |
| 29 | + line = 11L, line_stacked = 12L, line_stacked_percent = 13L, |
| 30 | + pie = 14L, |
| 31 | + scatter = 15L, scatter_straight = 16L, scatter_straight_markers = 17L, |
| 32 | + scatter_smooth = 18L, scatter_smooth_markers = 19L, |
| 33 | + radar = 20L, radar_markers = 21L, radar_filled = 22L |
| 34 | +) |
| 35 | + |
| 36 | +.LXW_CHART_LEGEND <- c( |
| 37 | + right = 1L, left = 2L, top = 3L, bottom = 4L, top_right = 5L, |
| 38 | + overlay_right = 6L, overlay_left = 7L, overlay_top_right = 8L, none = 9L |
| 39 | +) |
| 40 | + |
| 41 | +# --- Which features a chart type supports ------------------------------------ |
| 42 | +# |
| 43 | +# libxlsxwriter documents these restrictions in prose and then ignores them: |
| 44 | +# chart_set_hole_size() on a pie chart, or up-down bars on a column chart, are |
| 45 | +# accepted and silently dropped by Excel. The matrix is here so the refusal |
| 46 | +# happens in R, where it can name the type. |
| 47 | +.CHART_FAMILY <- function(type) { |
| 48 | + if (type %in% c("pie", "doughnut")) return("pie") |
| 49 | + if (startsWith(type, "scatter")) return("scatter") |
| 50 | + if (startsWith(type, "radar")) return("radar") |
| 51 | + if (startsWith(type, "bar") || startsWith(type, "column")) return("bar") |
| 52 | + if (startsWith(type, "line")) return("line") |
| 53 | + if (startsWith(type, "area")) return("area") |
| 54 | + "other" |
| 55 | +} |
| 56 | + |
| 57 | +# feature -> the families that support it |
| 58 | +.CHART_FEATURE_FAMILIES <- list( |
| 59 | + axes = c("scatter", "radar", "bar", "line", "area", "other"), |
| 60 | + hole_size = "pie", # doughnut only, narrowed below |
| 61 | + rotation = "pie", |
| 62 | + up_down_bars = "line", |
| 63 | + high_low_lines = "line", |
| 64 | + drop_lines = c("line", "area"), |
| 65 | + series_gap = "bar", |
| 66 | + series_overlap = "bar", |
| 67 | + smooth = c("line", "scatter") |
| 68 | +) |
| 69 | + |
| 70 | +# Is `feature` legal for `type`? Two features are narrower than their family. |
| 71 | +.chart_supports <- function(type, feature) { |
| 72 | + if (feature == "hole_size") return(identical(type, "doughnut")) |
| 73 | + fam <- .CHART_FAMILY(type) |
| 74 | + fam %in% .CHART_FEATURE_FAMILIES[[feature]] |
| 75 | +} |
| 76 | + |
| 77 | +.check_chart_feature <- function(type, feature, arg) { |
| 78 | + if (.chart_supports(type, feature)) return(invisible(NULL)) |
| 79 | + ok <- Filter(function(t) .chart_supports(t, feature), names(.LXW_CHART_TYPE)) |
| 80 | + stop(sprintf(paste0("`%s` does not apply to a \"%s\" chart; Excel drops it ", |
| 81 | + "silently.\n It applies to: %s."), |
| 82 | + arg, type, paste(ok, collapse = ", ")), call. = FALSE) |
| 83 | +} |
| 84 | + |
| 85 | +# --- Ranges a series points at ------------------------------------------------ |
| 86 | +# |
| 87 | +# A range may name a different sheet, so it cannot be resolved until every |
| 88 | +# sheet is known. Until then it is carried as given. |
| 89 | +.chart_range <- function(x, arg) { |
| 90 | + if (is.null(x)) return(NULL) |
| 91 | + if (is.character(x) && length(x) == 1L && !is.na(x)) return(list(spec = x)) |
| 92 | + if (is.list(x)) { |
| 93 | + nms <- names(x) |
| 94 | + if (is.null(nms) || any(!nzchar(nms))) |
| 95 | + stop(sprintf(paste0("`%s` list must be named, e.g. ", |
| 96 | + "list(sheet = \"Data\", cols = \"revenue\")"), arg), |
| 97 | + call. = FALSE) |
| 98 | + unknown <- setdiff(nms, c("sheet", "rows", "cols")) |
| 99 | + if (length(unknown)) |
| 100 | + stop(sprintf("unknown `%s` element(s): %s", arg, |
| 101 | + paste(unknown, collapse = ", ")), call. = FALSE) |
| 102 | + sheet <- x[["sheet"]] |
| 103 | + if (!is.null(sheet) && |
| 104 | + (!is.character(sheet) || length(sheet) != 1L || is.na(sheet))) |
| 105 | + stop(sprintf("`%s$sheet` must be a single sheet name", arg), |
| 106 | + call. = FALSE) |
| 107 | + return(list(spec = x[setdiff(nms, "sheet")], sheet = sheet)) |
| 108 | + } |
| 109 | + stop(sprintf(paste0("`%s` must be a range string or a list(sheet = , rows = ", |
| 110 | + ", cols = ) spec"), arg), call. = FALSE) |
| 111 | +} |
| 112 | + |
| 113 | +#' A data series within a chart |
| 114 | +#' |
| 115 | +#' @description |
| 116 | +#' `xl_chart_series()` names the values a chart plots, and optionally the |
| 117 | +#' categories to plot them against and a name for the legend. |
| 118 | +#' |
| 119 | +#' Each range may live on a different sheet from the chart, so it takes an |
| 120 | +#' optional `sheet`: |
| 121 | +#' |
| 122 | +#' * `"Data!B2:B10"` --- an A1 range, sheet-qualified; |
| 123 | +#' * `list(cols = "revenue")` --- resolved against the chart's own sheet; |
| 124 | +#' * `list(sheet = "Data", cols = "revenue")` --- against another sheet. |
| 125 | +#' |
| 126 | +#' A range that selects no data is an error rather than an empty chart. |
| 127 | +#' |
| 128 | +#' @param values The range holding the numbers to plot. |
| 129 | +#' @param categories The range holding the labels to plot them against. Omit |
| 130 | +#' for a chart that numbers its points. |
| 131 | +#' @param name The series name, shown in the legend. A string is always taken |
| 132 | +#' literally --- a series may legitimately be called `"Q1!"` --- so to take |
| 133 | +#' the name from a cell, give a range spec: |
| 134 | +#' `name = list(sheet = "Data", rows = 1, cols = 1)`. |
| 135 | +#' @param format An [xl_format] styling the series --- its line and fill. See |
| 136 | +#' [xl_chart()] for which format properties a chart can express. |
| 137 | +#' @param smooth Draw the line smoothed. Line and scatter charts only. |
| 138 | +#' @param invert_if_negative Fill negative values with the inverse colour. |
| 139 | +#' @return An `xl_chart_series` object. |
| 140 | +#' @family writexl |
| 141 | +#' @seealso [xl_chart] |
| 142 | +#' @export |
| 143 | +#' @examples |
| 144 | +#' xl_chart_series(values = list(cols = "revenue")) |
| 145 | +#' xl_chart_series(values = "Data!B2:B10", categories = "Data!A2:A10", |
| 146 | +#' name = "2024") |
| 147 | +xl_chart_series <- function(values, categories = NULL, name = NULL, |
| 148 | + format = NULL, smooth = NA, |
| 149 | + invert_if_negative = NA) { |
| 150 | + if (missing(values) || is.null(values)) |
| 151 | + stop("`values` must name the range holding the numbers to plot", |
| 152 | + call. = FALSE) |
| 153 | + if (!is.null(format) && !is_xl_format(format)) |
| 154 | + stop("`format` must be an xl_format object", call. = FALSE) |
| 155 | + nm <- if (is.null(name)) NULL |
| 156 | + else if (is.character(name) && length(name) == 1L && !is.na(name)) |
| 157 | + list(text = name) |
| 158 | + else .chart_range(name, "name") |
| 159 | + structure(.drop_null(list( |
| 160 | + values = .chart_range(values, "values"), |
| 161 | + categories = .chart_range(categories, "categories"), |
| 162 | + name = nm, format = format, |
| 163 | + smooth = .val_flag(smooth, "smooth"), |
| 164 | + invert_if_negative = .val_flag(invert_if_negative, "invert_if_negative") |
| 165 | + )), class = "xl_chart_series") |
| 166 | +} |
| 167 | + |
| 168 | +#' @export |
| 169 | +print.xl_chart_series <- function(x, ...) { |
| 170 | + p <- unclass(x) |
| 171 | + cat(sprintf("<xl_chart_series%s>\n", |
| 172 | + if (!is.null(p[["name"]][["text"]])) |
| 173 | + paste0(": ", p[["name"]][["text"]]) else "")) |
| 174 | + invisible(x) |
| 175 | +} |
| 176 | + |
| 177 | +#' Add a chart to a worksheet |
| 178 | +#' |
| 179 | +#' @description |
| 180 | +#' `xl_chart()` builds a chart from one or more [xl_chart_series()] and places |
| 181 | +#' it on a sheet, anchored to a cell. Pass one or a list of them as |
| 182 | +#' `xl_sheet(chart = )`. |
| 183 | +#' |
| 184 | +#' Placement works exactly as it does for [xl_image()] --- `at`, `scale`, |
| 185 | +#' `offset`, `position`, `description` and `decorative` mean the same things, |
| 186 | +#' because libxlsxwriter describes both with the same fields. |
| 187 | +#' |
| 188 | +#' @section What a chart type supports: |
| 189 | +#' Excel silently drops options a chart type cannot use, so writexl refuses them |
| 190 | +#' instead, naming the types that would work. Pie and doughnut charts have no |
| 191 | +#' axes; only a doughnut has a hole; only pie and doughnut rotate; up-down bars |
| 192 | +#' and high-low lines are line-only; the series gap and overlap are bar and |
| 193 | +#' column only. |
| 194 | +#' |
| 195 | +#' @param type The chart type: `"column"`, `"bar"`, `"line"`, `"pie"`, |
| 196 | +#' `"doughnut"`, `"area"`, `"scatter"`, `"radar"`, and the stacked, |
| 197 | +#' percent-stacked, smoothed and marker variants. |
| 198 | +#' @param series One [xl_chart_series()], or a list of them. |
| 199 | +#' @param title The chart title: a string, or a range holding one. `FALSE` |
| 200 | +#' removes the title Excel would otherwise generate. |
| 201 | +#' @param at The cell the chart's top-left corner is anchored to. |
| 202 | +#' @param scale Scale factor: one number for both axes, or `c(x, y)`. |
| 203 | +#' @param offset Offset from the anchor cell's corner in pixels, as `c(x, y)`. |
| 204 | +#' @param position How the chart behaves when rows and columns change size; see |
| 205 | +#' [xl_image()]. |
| 206 | +#' @param description Alt text, for screen readers. |
| 207 | +#' @param decorative Mark the chart as decorative, so screen readers skip it. |
| 208 | +#' @param style Excel's built-in chart style, 1--48. |
| 209 | +#' @return An `xl_chart` object. |
| 210 | +#' @family writexl |
| 211 | +#' @seealso [xl_chart_series], [xl_sheet] |
| 212 | +#' @export |
| 213 | +#' @examples |
| 214 | +#' xl_chart("column", xl_chart_series(values = list(cols = "revenue"))) |
| 215 | +#' xl_chart("pie", xl_chart_series(values = "Data!B2:B5"), title = "Share") |
| 216 | +xl_chart <- function(type, series, title = NULL, at = "A1", scale = 1, |
| 217 | + offset = NULL, position = "move_and_size", |
| 218 | + description = NULL, decorative = FALSE, style = NA) { |
| 219 | + ty <- .val_enum(type, names(.LXW_CHART_TYPE), "type") |
| 220 | + if (is.null(ty)) |
| 221 | + stop("`type` must name a chart type, e.g. \"column\"", call. = FALSE) |
| 222 | + if (missing(series)) |
| 223 | + stop("`series` must give at least one xl_chart_series() to plot", |
| 224 | + call. = FALSE) |
| 225 | + ss <- .chart_series_list(series) |
| 226 | + if (!length(ss)) |
| 227 | + stop("a chart needs at least one series", call. = FALSE) |
| 228 | + |
| 229 | + for (i in seq_along(ss)) { |
| 230 | + p <- unclass(ss[[i]]) |
| 231 | + if (isTRUE(p[["smooth"]])) |
| 232 | + .check_chart_feature(ty, "smooth", sprintf("series[[%d]]$smooth", i)) |
| 233 | + } |
| 234 | + |
| 235 | + pair <- function(x, arg, what) { |
| 236 | + if (is.null(x)) return(NULL) |
| 237 | + if (!is.numeric(x) || !length(x) %in% c(1L, 2L) || anyNA(x)) |
| 238 | + stop(sprintf("`%s` must be one number or two, %s", arg, what), |
| 239 | + call. = FALSE) |
| 240 | + if (length(x) == 1L) x <- c(x, x) |
| 241 | + as.numeric(x) |
| 242 | + } |
| 243 | + sc <- pair(scale, "scale", "as c(x, y)") |
| 244 | + if (!is.null(sc) && any(sc <= 0)) |
| 245 | + stop("`scale` must be positive", call. = FALSE) |
| 246 | + |
| 247 | + ttl <- if (is.null(title)) NULL |
| 248 | + else if (identical(title, FALSE)) list(off = TRUE) |
| 249 | + else if (is.character(title) && length(title) == 1L && !is.na(title)) |
| 250 | + list(text = title) |
| 251 | + else .chart_range(title, "title") |
| 252 | + |
| 253 | + structure(.drop_null(list( |
| 254 | + type = ty, series = ss, title = ttl, |
| 255 | + at = at, scale = sc, offset = pair(offset, "offset", "of pixels as c(x, y)"), |
| 256 | + position = .val_enum(position, names(.LXW_OBJECT_POSITION), "position"), |
| 257 | + description = if (is.null(description)) NULL else { |
| 258 | + if (!is.character(description) || length(description) != 1L || |
| 259 | + is.na(description)) |
| 260 | + stop("`description` must be a single non-NA string", call. = FALSE) |
| 261 | + description |
| 262 | + }, |
| 263 | + decorative = .val_flag(decorative, "decorative"), |
| 264 | + style = .val_int(style, "style", min = 1, max = 48) |
| 265 | + )), class = "xl_chart") |
| 266 | +} |
| 267 | + |
| 268 | +#' @export |
| 269 | +print.xl_chart <- function(x, ...) { |
| 270 | + p <- unclass(x) |
| 271 | + cat(sprintf("<xl_chart: %s, %d series%s>\n", p[["type"]], |
| 272 | + length(p[["series"]]), |
| 273 | + if (!is.null(p[["title"]][["text"]])) |
| 274 | + paste0(", \"", p[["title"]][["text"]], "\"") else "")) |
| 275 | + invisible(x) |
| 276 | +} |
| 277 | + |
| 278 | +# Normalise one series or a list of them. |
| 279 | +.chart_series_list <- function(series, arg = "series") { |
| 280 | + if (is.null(series)) return(list()) |
| 281 | + ss <- if (inherits(series, "xl_chart_series")) list(series) else series |
| 282 | + if (!is.list(ss)) |
| 283 | + stop(sprintf("`%s` must be an xl_chart_series object or a list of them", |
| 284 | + arg), call. = FALSE) |
| 285 | + for (i in seq_along(ss)) |
| 286 | + if (!inherits(ss[[i]], "xl_chart_series")) |
| 287 | + stop(sprintf("`%s[[%d]]` must be an xl_chart_series object", arg, i), |
| 288 | + call. = FALSE) |
| 289 | + ss |
| 290 | +} |
| 291 | + |
| 292 | +# Normalise one chart or a list of them. |
| 293 | +.chart_list <- function(chart, arg = "chart") { |
| 294 | + if (is.null(chart)) return(list()) |
| 295 | + cs <- if (inherits(chart, "xl_chart")) list(chart) else chart |
| 296 | + if (!is.list(cs)) |
| 297 | + stop(sprintf("`%s` must be an xl_chart object or a list of them", arg), |
| 298 | + call. = FALSE) |
| 299 | + for (i in seq_along(cs)) |
| 300 | + if (!inherits(cs[[i]], "xl_chart")) |
| 301 | + stop(sprintf("`%s[[%d]]` must be an xl_chart object", arg, i), |
| 302 | + call. = FALSE) |
| 303 | + cs |
| 304 | +} |
0 commit comments