R/geom-.r

Defines functions check_aesthetics

#' @include legend-draw.r
NULL

#' @section Geoms:
#'
#' All \code{geom_*} functions (like \code{geom_point}) return a layer that
#' contains a \code{Geom*} object (like \code{GeomPoint}). The \code{Geom*}
#' object is responsible for rendering the data in the plot.
#'
#' Each of the \code{Geom*} objects is a \code{\link{gganimintproto}} object, descended
#' from the top-level \code{Geom}, and each implements various methods and
#' fields. To create a new type of Geom object, you typically will want to
#' implement one or more of the following:
#'
#' Compared to \code{Stat} and \code{Position}, \code{Geom} is a little
#' different because the execution of the setup and compute functions is
#' split up. \code{setup_data} runs before position adjustments, and
#' \code{draw_layer} is not run until render time,  much later. This
#' means there is no \code{setup_params} because it's hard to communicate
#' the changes.
#'
#' \itemize{
#'   \item Override either \code{draw_panel(self, data, panel_scales, coord)} or
#'     \code{draw_group(self, data, panel_scales, coord)}. \code{draw_panel} is
#'     called once per panel, \code{draw_group} is called once per group.
#'
#'     Use \code{draw_panel} if each row in the data represents a
#'     single element. Use \code{draw_group} if each group represents
#'     an element (e.g. a smooth, a violin).
#'
#'     \code{data} is a data frame of scaled aesthetics. \code{panel_scales}
#'     is a list containing information about the scales in the current
#'     panel. \code{coord} is a coordinate specification. You'll
#'     need to call \code{coord$transform(data, panel_scales)} to work
#'     with non-Cartesian coords. To work with non-linear coordinate systems,
#'     you typically need to convert into a primitive geom (e.g. point, path
#'     or polygon), and then pass on to the corresponding draw method
#'     for munching.
#'
#'     Must return a grob. Use \code{\link{zeroGrob}} if there's nothing to
#'     draw.
#'   \item \code{draw_key}: Renders a single legend key.
#'   \item \code{required_aes}: A character vector of aesthetics needed to
#'     render the geom.
#'   \item \code{default_aes}: A list (generated by \code{\link{aes}()} of
#'     default values for aesthetics.
#'   \item \code{reparameterise}: Converts width and height to xmin and xmax,
#'     and ymin and ymax values. It can potentially set other values as well.
#' }
#' @rdname animint2-gganimintproto
#' @format NULL
#' @usage NULL
#' @export
Geom <- gganimintproto("Geom",
  required_aes = character(),
  non_missing_aes = character(),

  default_aes = aes(),

  draw_key = draw_key_point,

  handle_na = function(self, data, params) {
    remove_missing(data, params$na.rm,
      c(self$required_aes, self$non_missing_aes),
      snake_class(self)
    )
  },

  draw_layer = function(self, data, params, panel, coord) {
    if (empty(data)) {
      n <- if (is.factor(data$PANEL)) nlevels(data$PANEL) else 1L
      return(rep(list(zeroGrob()), n))
    }

    # Trim off extra parameters
    params <- params[intersect(names(params), self$parameters())]

    args <- c(list(quote(data), quote(panel_scales), quote(coord)), params)
    plyr::dlply(data, "PANEL", function(data) {
      if (empty(data)) return(zeroGrob())

      panel_scales <- panel$ranges[[data$PANEL[1]]]
      do.call(self$draw_panel, args)
    }, .drop = FALSE)
  },

  draw_panel = function(self, data, panel_scales, coord, ...) {
    groups <- split(data, factor(data$group))
    grobs <- lapply(groups, function(group) {
      self$draw_group(group, panel_scales, coord, ...)
    })

    ggname(snake_class(self), gTree(
      children = do.call("gList", grobs)
    ))
  },

  draw_group = function(self, data, panel_scales, coord) {
    stop("Not implemented")
  },

  setup_data = function(data, params) data,

  # Combine data with defaults and set aesthetics from parameters
  use_defaults = function(self, data, params = list()) {
    # Fill in missing aesthetics with their defaults
    missing_aes <- setdiff(names(self$default_aes), names(data))
    if (empty(data)) {
      data <- plyr::quickdf(self$default_aes[missing_aes])
    } else {
      data[missing_aes] <- self$default_aes[missing_aes]
    }

    # Override mappings with params
    aes_params <- intersect(self$aesthetics(), names(params))
    check_aesthetics(params[aes_params], nrow(data))
    data[aes_params] <- params[aes_params]
    data
  },

  # Most parameters for the geom are taken automatically from draw_panel() or
  # draw_groups(). However, some additional parameters may be needed
  # for setup_data() or handle_na(). These can not be imputed automatically,
  # so the slightly hacky "extra_params" field is used instead. By
  # default it contains `na.rm`
  extra_params = c("na.rm"),

  parameters = function(self, extra = FALSE) {
    # Look first in draw_panel. If it contains ... then look in draw groups
    panel_args <- names(gganimintproto_formals(self$draw_panel))
    group_args <- names(gganimintproto_formals(self$draw_group))
    args <- if ("..." %in% panel_args) group_args else panel_args

    # Remove arguments of defaults
    args <- setdiff(args, names(gganimintproto_formals(Geom$draw_group)))

    if (extra) {
      args <- union(args, self$extra_params)
    }
    args
  },

  aesthetics = function(self) {
    c(union(self$required_aes, names(self$default_aes)), "group")
  },

  pre_process = function(g, g.data, ranges){
    list(g = g, g.data = g.data)
  },

  ## Save a layer to disk, save and return meta-data.
  ## l- one layer of the ggplot object.
  ## d- one layer of calculated data from ggplot_build(p).
  ## meta- environment of meta-data.
  ## layer_name- name of layer
  ## ggplot- ggplot
  ## built- built list
  ## AnimationInfo- animation list
  ## ID- number starting from 1
  ## returns- list representing a layer, with corresponding aesthetics, ranges, and groups.
  export_animint = function(l, d, meta, layer_name, ggplot, built, AnimationInfo) {
    xminv <- y <- xmaxv <- chunks.for <- NULL
    ## above to avoid NOTE on CRAN check.
    g <- list(geom=strsplit(layer_name, "_")[[1]][2])
    g$classed <- layer_name

    ranges <- built$panel$ranges

    ## needed for when group, etc. is an expression:
    g$aes <- sapply(l$mapping, function(k) as.character(as.expression(k)))

    ## use un-named parameters so that they will not be exported
    ## to JSON as a named object, since that causes problems with
    ## e.g. colour.
    ## 'colour', 'size' etc. have been moved to aes_params
    g$params <- getLayerParams(l)

    ## Make a list of variables to use for subsetting. subset_order is the
    ## order in which these variables will be accessed in the recursive
    ## JavaScript array structure.

    ## subset_order IS in fact useful with geom_segment! For example, in
    ## the first plot in the breakpointError example, the geom_segment has
    ## the following exported data in plot.json

    ## "subset_order": [
    ##  "showSelected",
    ## "showSelected2"
    ## ],

    ## This information is used to parse the recursive array data structure
    ## that allows efficient lookup of subsets of data in JavaScript. Look at
    ## the Firebug DOM browser on
    ## http://sugiyama-www.cs.titech.ac.jp/~toby/animint/breakpoints/index.html
    ## and navigate to plot.Geoms.geom3.data. You will see that this is a
    ## recursive array that can be accessed via
    ## data[segments][bases.per.probe] which is an un-named array
    ## e.g. [{row1},{row2},...] which will be bound to the <line> elements by
    ## D3. The key point is that the subset_order array stores the order of the
    ## indices that will be used to select the current subset of data (in
    ## this case showSelected=segments, showSelected2=bases.per.probe). The
    ## currently selected values of these variables are stored in
    ## plot.Selectors.

    ## Separate .variable/.value selectors
    s.aes <- selectSSandCS(g$aes)
    meta$selector.aes[[g$classed]] <- s.aes

    ## Do not copy group unless it is specified in aes, and do not copy
    ## showSelected variables which are specified multiple times.
    do.not.copy <- colsNotToCopy(g, s.aes)
    copy.cols <- ! names(d) %in% do.not.copy

    g.data <- d[copy.cols]

    is.ss <- names(g$aes) %in% s.aes$showSelected$one
    show.vars <- g$aes[is.ss]
    pre.subset.order <- as.list(names(show.vars))

    is.cs <- names(g$aes) %in% s.aes$clickSelects$one
    update.vars <- g$aes[is.ss | is.cs]

    update.var.names <- if(0 < length(update.vars)){
      data.frame(variable=names(update.vars), value=NA)
    }

    interactive.aes <- with(s.aes, {
      rbind(clickSelects$several, showSelected$several,
            update.var.names)
    })

    ## Construct the selector.
    for(row.i in seq_along(interactive.aes$variable)){
      aes.row <- interactive.aes[row.i, ]
      is.variable.value <- !is.na(aes.row$value)
      selector.df <- if(is.variable.value){
        selector.vec <- g.data[[paste(aes.row$variable)]]
        data.frame(value.col=aes.row$value,
                  selector.name=unique(paste(selector.vec)))
      }else{
        value.col <- paste(aes.row$variable)
        data.frame(value.col,
                  selector.name=update.vars[[value.col]])
      }
      for(sel.i in 1:nrow(selector.df)){
        sel.row <- selector.df[sel.i,]
        value.col <- paste(sel.row$value.col)
        selector.name <- paste(sel.row$selector.name)
        ## If this selector was defined by .variable .value aes, then we
        ## will not generate selectize widgets.
        meta$selectors[[selector.name]]$is.variable.value <- is.variable.value
        ## If this selector has no defined type yet, we define it once
        ## and for all here, so we can use it later for chunk
        ## separation.
        if(is.null(meta$selectors[[selector.name]]$type)){
          selector.type <- meta$selector.types[[selector.name]]
          if(is.null(selector.type))selector.type <- "single"
          stopifnot(is.character(selector.type))
          stopifnot(length(selector.type)==1)
          stopifnot(selector.type %in% c("single", "multiple"))
          meta$selectors[[selector.name]]$type <- selector.type
        }
        ## If this selector does not have any clickSelects then we show
        ## the selectize widgets by default.
        for(look.for in c("showSelected", "clickSelects")){
          if(grepl(look.for, aes.row$variable)){
            meta$selectors[[selector.name]][[look.for]] <- TRUE
          }
        }
        ## We also store all the values of this selector in this layer,
        ## so we can accurately set levels after all geoms have been
        ## compiled.
        value.vec <- unique(g.data[[value.col]])
        key <- paste(g$classed, row.i, sel.i)
        meta$selector.values[[selector.name]][[key]] <-
          list(values=paste(value.vec), update=g$classed)
      }
    }

    is.show <- grepl("showSelected", names(g$aes))
    has.show <- any(is.show)
    ## Error if non-identity stat is used with showSelected, since
    ## typically the stats will delete the showSelected column from the
    ## built data set. For example geom_bar + stat_bin doesn't make
    ## sense with clickSelects/showSelected, since two
    ## clickSelects/showSelected values may show up in the same bin.
    stat.type <- class(l$stat)[[1]]
    checkForNonIdentityAndSS(stat.type, has.show, is.show, l,
                            g$classed, names(g.data), names(g$aes))

    ## Warn if non-identity position is used with animint aes.
    position.type <- class(l$position)[[1]]
    if(has.show && position.type != "PositionIdentity"){
      print(l)
      warning("showSelected only works with position=identity, problem: ",
              g$classed)
    }

    ##print("before pre-processing")

    ## Pre-process some complex geoms so that they are treated as
    ## special cases of basic geoms. In ggplot2, this processing is done
    ## in the draw method of the geoms.

    processed_values <- l$geom$pre_process(g, g.data, ranges)
    g <- processed_values$g
    g.data <- processed_values$g.data
    ## Check g.data for color/fill - convert to hexadecimal so JS can parse correctly.
    for(color.var in c("colour", "color", "fill", "colour_off", "color_off", "fill_off")){
      if(color.var %in% names(g.data)){
        g.data[,color.var] <- toRGB(g.data[,color.var])
      }
      if(color.var %in% names(g$params)){
        g$params[[color.var]] <- toRGB(g$params[[color.var]])
      }
    }

    has.no.fill <- g$geom %in% c("path", "line")
    zero.size <- any(g.data$size == 0, na.rm=TRUE)
    if(zero.size && has.no.fill){
      warning(sprintf("geom_%s with size=0 will be invisible",g$geom))
    }

    ## raise warning for using *_off params without clickSelects
    has.off <- any(names(g$params) %like% "_off")
    has.no.cs <- !any(is.cs)
    if(has.no.cs && has.off){
      off.vec <- grep( "_off$", names(g$params), value = TRUE)
      warning(sprintf("%s has %s which is not used because this geom has no clickSelects; please specify clickSelects or remove %s",
      g$classed, paste(off.vec, collapse=", "), paste(off.vec, collapse=", ")))
    }

    ## raise warning for geoms does not support fill
    has.fill.off <- any(names(g$params) == "fill_off")
    no.fill.geom <- c("path", "line", "segment", "linerange", "hline", "vline")
    if (g$geom %in% no.fill.geom && has.fill.off) {
      g$params <- g$params[!names(g$params) %in% "fill_off"]
      warning(sprintf("%s has fill_off which is not supported.", g$classed))
    }
    ## TODO: coord_transform maybe won't work for
    ## geom_dotplot|rect|segment and polar/log transformations, which
    ## could result in something nonlinear. For the time being it is
    ## best to just ignore this, but you can look at the source of
    ## e.g. geom-rect.r in ggplot2 to see how they deal with this by
    ## doing a piecewise linear interpolation of the shape.

    ## Flip axes in case of coord_flip
    if(inherits(ggplot$coordinates, "CoordFlip")){
      names(g.data) <- switch_axes(names(g.data))
    }

    ## Output types
    ## Check to see if character type is d3's rgb type.
    g$types <- sapply(g.data, function(x) {
      type <- paste(class(x), collapse="-")
      if(type == "character"){
        if(sum(!is.rgb(x))==0){
          "rgb"
        }else if(sum(!is.linetype(x))==0){
          "linetype"
        }else {
          "character"
        }
      }else{
        type
      }
    })
    g$types[["group"]] <- "character"

    ## convert ordered factors to unordered factors so javascript
    ## doesn't flip out.
    ordfactidx <- which(g$types=="ordered-factor")
    for(i in ordfactidx){
      g.data[[i]] <- factor(as.character(g.data[[i]]))
      g$types[[i]] <- "factor"
    }

    ## Get unique values of time variable.
    time.col <- NULL
    if(is.list(AnimationInfo$time)){ # if this is an animation,
      g.time.list <- list()
      for(c.or.s in names(s.aes)){
        cs.info <- s.aes[[c.or.s]]
        for(a in cs.info$one){
          if(g$aes[[a]] == AnimationInfo$time$var){
            g.time.list[[a]] <- g.data[[a]]
            time.col <- a
          }
        }
        for(row.i in seq_along(cs.info$several$value)){
          cs.row <- cs.info$several[row.i,]
          c.name <- paste(cs.row$variable)
          is.time <- g.data[[c.name]] == AnimationInfo$time$var
          g.time.list[[c.name]] <- g.data[is.time, paste(cs.row$value)]
        }
      }
      u.vals <- unique(unlist(g.time.list))
      if(length(u.vals)){
        AnimationInfo$timeValues[[paste(g$classed)]] <- sort(u.vals)
      }
    }
    ## Make the time variable the first subset_order variable.
    if(length(time.col)){
      pre.subset.order <- pre.subset.order[order(pre.subset.order != time.col)]
    }

    ## Determine which showSelected values to use for breaking the data
    ## into chunks. This is a list of variables which have the same
    ## names as the selectors. E.g. if chunk_order=list("year") then
    ## when year is clicked, we may need to download some new data for
    ## this geom.
    subset.vec <- unlist(pre.subset.order)
    if("chunk_vars" %in% names(g$params)){ #designer-specified chunk vars.
      designer.chunks <- g$params$chunk_vars
      if(!is.character(designer.chunks)){
        stop("chunk_vars must be a character vector; ",
            "use chunk_vars=character() to specify 1 chunk")
      }
      not.subset <- !designer.chunks %in% g$aes[subset.vec]
      if(any(not.subset)){
        stop("invalid chunk_vars ",
            paste(designer.chunks[not.subset], collapse=" "),
            "; possible showSelected variables: ",
            paste(g$aes[subset.vec], collapse=" "))
      }
      is.chunk <- g$aes[subset.vec] %in% designer.chunks
      chunk.cols <- subset.vec[is.chunk]
      nest.cols <- subset.vec[!is.chunk]
    }else{ #infer a default, either 0 or 1 chunk vars:
      if(length(meta$selectors)==0){
        ## no selectors, just make 1 chunk.
        nest.cols <- subset.vec
        chunk.cols <- NULL
      }else{
        selector.types <- sapply(meta$selectors, "[[", "type")
        selector.names <- g$aes[subset.vec]
        subset.types <- selector.types[selector.names]
        can.chunk <- subset.types != "multiple"
        names(can.chunk) <- subset.vec
        ## Guess how big the chunk files will be, and reduce the number of
        ## chunks if there are any that are too small.
        tmp <- tempfile()
        some.lines <- rbind(head(g.data), tail(g.data))
        write.table(some.lines, tmp,
                    col.names=FALSE,
                    quote=FALSE, row.names=FALSE, sep="\t")
        bytes <- file.info(tmp)$size
        bytes.per.line <- bytes/nrow(some.lines)
        bad.chunk <- function(){
          if(all(!can.chunk))return(NULL)
          can.chunk.cols <- subset.vec[can.chunk]
          maybe.factors <- g.data[, can.chunk.cols, drop=FALSE]
          for(N in names(maybe.factors)){
            maybe.factors[[N]] <- paste(maybe.factors[[N]])
          }
          rows.per.chunk <- table(maybe.factors)
          bytes.per.chunk <- rows.per.chunk * bytes.per.line
          if(all(4096 < bytes.per.chunk))return(NULL)
          ## If all of the tsv chunk files are greater than 4KB, then we
          ## return NULL here to indicate that the current chunk
          ## variables (indicated in can.chunk) are fine.

          ## In other words, the compiler will not break a geom into
          ## chunks if any of the resulting chunk tsv files is estimated
          ## to be less than 4KB (of course, if the layer has very few
          ## data overall, the compiler creates 1 file which may be less
          ## than 4KB, but that is fine).
          dim.byte.list <- list()
          if(length(can.chunk.cols) == 1){
            dim.byte.list[[can.chunk.cols]] <- sum(bytes.per.chunk)
          }else{
            for(dim.i in seq_along(can.chunk.cols)){
              dim.name <- can.chunk.cols[[dim.i]]
              dim.byte.list[[dim.name]] <-
                apply(bytes.per.chunk, -dim.i, sum)
            }
          }
          selector.df <-
            data.frame(chunks.for=length(rows.per.chunk),
                      chunks.without=sapply(dim.byte.list, length),
                      min.bytes=sapply(dim.byte.list, min))
          ## chunks.for is the number of chunks you get if you split the
          ## data set using just this column. If it is 1, then it is
          ## fine to chunk on this variable (since we certainly won't
          ## make more than 1 small tsv file) and in fact we want to
          ## chunk on this variable, since then this layer's data won't
          ## be downloaded at first if it is not needed.
          not.one <- subset(selector.df, 1 < chunks.for)
          if(nrow(not.one) == 0){
            NULL
          }else{
            rownames(not.one)[[which.max(not.one$min.bytes)]]
          }
        }
        while({
          bad <- bad.chunk()
          !is.null(bad)
        }){
          can.chunk[[bad]] <- FALSE
        }
        if(any(can.chunk)){
          nest.cols <- subset.vec[!can.chunk]
          chunk.cols <- subset.vec[can.chunk]
        }else{
          nest.cols <- subset.vec
          chunk.cols <- NULL
        }
      } # meta$selectors > 0
    }

    # If there is only one PANEL, we don't need it anymore.
    # g$PANEL <- unique(g.data[["PANEL"]])
    plot.has.panels <- nrow(built$panel$layout) > 1
    g.data <- removeUniquePanelValue(g.data, plot.has.panels)

    ## Also add pointers to these chunks from the related selectors.
    if(length(chunk.cols)){
      selector.names <- as.character(g$aes[chunk.cols])
      chunk.name <- paste(selector.names, collapse="_")
      g$chunk_order <- as.list(selector.names)
      for(selector.name in selector.names){
        meta$selectors[[selector.name]]$chunks <-
          unique(c(meta$selectors[[selector.name]]$chunks, chunk.name))
      }
    }else{
      g$chunk_order <- list()
    }
    g$nest_order <- as.list(nest.cols)
    names(g$chunk_order) <- NULL
    names(g$nest_order) <- NULL
    g$subset_order <- g$nest_order

    ## If this plot has more than one PANEL then add it to subset_order
    ## and nest_order.
    if(plot.has.panels){
      g$subset_order <- c(g$subset_order, "PANEL")
      g$nest_order <- c(g$nest_order, "PANEL")
    }

    ## nest_order should contain both .variable .value aesthetics, but
    ## subset_order should contain only .variable.
    if((nrow(s.aes$showSelected$several) > 0)){
      g$nest_order <- with(s.aes$showSelected$several, {
        c(g$nest_order, paste(variable), paste(value))
      })
      g$subset_order <-
        c(g$subset_order, paste(s.aes$showSelected$several$variable))
    }

    ## group should be the last thing in nest_order, if it is present.
    data.object.geoms <- c("line", "path", "ribbon", "polygon")
    if("group" %in% names(g$aes) && g$geom %in% data.object.geoms){
      g$nest_order <- c(g$nest_order, "group")
    }

    ## Some geoms should be split into separate groups if there are NAs.
    if(any(is.na(g.data)) && "group" %in% names(g$aes)){
      sp.cols <- unlist(c(chunk.cols, g$nest_order))
      order.args <- list()
      for(sp.col in sp.cols){
        order.args[[sp.col]] <- g.data[[sp.col]]
      }
      ord <- do.call(order, order.args)
      g.data <- g.data[ord,]
      is.missing <- apply(is.na(g.data), 1, any)
      diff.vec <- diff(is.missing)
      new.group.vec <- c(FALSE, diff.vec == 1)
      for(chunk.col in sp.cols){
        one.col <- g.data[[chunk.col]]
        is.diff <- c(FALSE, one.col[-1] != one.col[-length(one.col)])
        new.group.vec[is.diff] <- TRUE
      }
      subgroup.vec <- cumsum(new.group.vec)
      g.data$group <- subgroup.vec
    }

    ## Find infinite values and replace with range min/max.
    for(xy in c("x", "y")){
      range.name <- paste0(xy, ".range")
      range.mat <- sapply(ranges, "[[", range.name)
      xy.col.vec <- grep(paste0("^", xy), names(g.data), value=TRUE)
      xy.col.df <- g.data[, xy.col.vec, drop=FALSE]
      cmp.list <- list(`<`, `>`)#order is important here!
      for(row.i in seq_along(cmp.list)){
        ## PANEL may be a factor so it is not good enough to do
        ## if(is.numeric(g.data$PANEL))
        panel.vec <- if("PANEL" %in% names(g.data)){
          g.data$PANEL
        }else{
          rep(1, nrow(g.data))
        }
        extreme.vec <- range.mat[row.i, panel.vec]
        cmp <- cmp.list[[row.i]]
        to.rep <- cmp(xy.col.df, extreme.vec)
        row.vec <- row(to.rep)[to.rep]
        xy.col.df[to.rep] <- extreme.vec[row.vec]
      }
      g.data[, xy.col.vec] <- xy.col.df
    }

    ## Determine if there are any "common" data that can be saved
    ## separately to reduce disk usage.
    data.or.null <- getCommonChunk(g.data, chunk.cols, g$aes)
    g.data.varied <- if(is.null(data.or.null)){
      split_recursive(na.omit(g.data), chunk.cols)
    }else{
      g$columns$common <- as.list(names(data.or.null$common))
      tsv.name <- sprintf("%s_chunk_common.tsv", g$classed)
      tsv.path <- file.path(meta$out.dir, tsv.name)
      write.table(data.or.null$common, tsv.path,
                  quote = FALSE, row.names = FALSE,
                  sep = "\t")
      data.or.null$varied
    }

    list(g=g, g.data.varied=g.data.varied, timeValues=AnimationInfo$timeValues)
  }
)


#' Graphical units
#'
#' Multiply size in mm by these constants in order to convert to the units
#' that grid uses internally for \code{lwd} and \code{fontsize}.
#'
#' @name graphical-units
NULL

#' @export
#' @rdname graphical-units
.pt <- 72.27 / 25.4
#' @export
#' @rdname graphical-units
.stroke <- 96 / 25.4

check_aesthetics <- function(x, n) {
  ns <- vapply(x, length, numeric(1))
  good <- ns == 1L | ns == n

  if (all(good)) {
    return()
  }

  stop(
    "Aesthetics must be either length 1 or the same as the data (", n, "): ",
    paste(names(!good), collapse = ", "),
    call. = FALSE
  )
}

Try the animint2 package in your browser

Any scripts or data that you put into this service are public.

animint2 documentation built on Nov. 22, 2023, 1:07 a.m.