R/ggplotly.R

#' Send a ggplot to plot.ly.
#' @export
#' @param gg a ggplot.
#' @param p a plotly interface object.
#' @example examples/ggplotly.R
ggplotly <- function(gg, p){
  if(!is.ggplot(gg)){
    stop("gg must be a ggplot")
  }
  if(!is.function(p$plotly)){
    stop("p must be a plotly interface object")
  }
  pargs <- gg2list(gg)
  resp <- do.call(p$plotly, pargs)
  browseURL(resp$url)
  invisible(list(data=pargs, response=resp))
}

#' Convert R pch point codes to plotly "symbol" codes.
pch2symbol <- c("0"="square",
                "1"="circle",
                "2"="triangle-up",
                "3"="cross",
                "4"="x",
                "5"="diamond",
                "6"="triangle-down",
                "15"="square",
                "16"="circle",
                "17"="triangle-up",
                "18"="diamond",
                "19"="circle",
                "20"="circle",
                "22"="square",
                "23"="diamond",
                "24"="triangle-up",
                "25"="triangle-down",
                "o"="circle",
                "O"="circle",
                "+"="cross")

#' Convert ggplot2 aes to plotly "marker" codes.
aes2marker <- c(alpha="opacity",
                pch="symbol",
                colour="color",
                size="size",
                ##TODO="line", ## line color, size, and dash
                shape="symbol",
                text="text")
marker.defaults <- c(alpha=1,
                     shape="o",
                     pch="o",
                     colour="black")
#' Convert ggplot2 aes to line parameters.
aes2line <- c(linetype="dash",
              colour="color",
              size="width",
              text="text")
line.defaults <-
  list(linetype="solid",
       colour="black",
       size=2)

numeric.lty <-
  c("1"="solid",
    "2"="dash",
    "3"="dot",
    "4"="dashdot",
    "5"="longdash",
    "6"="longdashdot")

named.lty <-
  c("solid"="solid",
    "blank"="none",
    "dashed"="dash",
    "dotted"="dotted",
    "dotdash"="dashdot",
    "longdash"="longdash",
    "twodash"="dash")

## TODO: does plotly support this??
coded.lty <-
  c("22"="dash",
    "42"="dot",
    "44"="dashdot",
    "13"="longdash",
    "1343"="longdashdot",
    "73"="dash",
    "2262"="dotdash",
    "12223242"="dotdash",
    "F282"="dash",
    "F4448444"="dash",
    "224282F2"="dash",
    "F1"="dash")

#' Convert R lty line type codes to plotly "dash" codes.
lty2dash <- c(numeric.lty, named.lty, coded.lty)

#' Convert a ggplot to a list.
#' @import ggplot2 plotly
#' @param p ggplot2 plot.
#' @return list representing a ggplot.
#' @export
gg2list <- function(p){
  ## Always use identity size scale so that plot.ly gets the real
  ## units for the size variables.
  p <- p+scale_size_identity()
  plist <- list()
  ## Before building the ggplot, we would like to add aes(name) to
  ## figure out what the object group is later.
  for(layer.i in seq_along(p$layers)){
    a <- c(p$layers[[layer.i]]$mapping, p$mapping)
    group.vars <- c("colour", "color", "col",
                    "fill",
                    "linetype", "lty",
                    "shape", "pch")
    group.var <- a$name
    for(gv in group.vars){
      if(is.null(group.var)){
        g.expr <- a[[gv]]
        if(!is.null(g.expr)){
          group.var <- g.expr
        }
      }
    }
    p$layers[[layer.i]]$mapping$name <- group.var
  }
  plistextra <- ggplot2::ggplot_build(p)
  ## NOTE: data from ggplot_build have scales already applied. This
  ## may be a bad thing for log scales.
  for(sc in plistextra$plot$scales$scales){
    if(sc$scale_name == "manual"){
      plist$scales[[sc$aesthetics]] <- sc$palette(0)
    }else if(sc$scale_name == "brewer"){
      plist$scales[[sc$aesthetics]] <- sc$palette(length(sc$range$range))
    }else if(sc$scale_name == "hue"){
      plist$scales[[sc$aesthetics]] <- sc$palette(length(sc$range$range))
    }else if(sc$scale_name == "linetype_d"){
      plist$scales[[sc$aesthetics]] <- sc$palette(length(sc$range$range))
    }else if(sc$scale_name == "alpha_c"){
      plist$scales[[sc$aesthetics]] <- sc$palette(sc$range$range)
    }else if(sc$scale_name == "size_c"){
      plist$scales[[sc$aesthetics]] <- sc$palette(sc$range$range)
    }else if(sc$scale_name == "gradient"){
      plist$scales[[sc$aesthetics]] <- ggplot2:::scale_map(sc, ggplot2:::scale_breaks(sc))
    }
  }
  for(i in seq_along(plistextra$plot$layers)){
    ## This is the layer from the original ggplot object.
    L <- plistextra$plot$layers[[i]]

    ## for each layer, there is a correpsonding data.frame which
    ## evaluates the aesthetic mapping.
    df <- plistextra$data[[i]]

    ## This extracts essential info for this geom/layer.
    g <- layer2list(L, df, plistextra$panel$ranges[[1]])
    
    ## Idea: use the ggplot2:::coord_transform(coords, data, scales)
    ## function to handle cases like coord_flip. scales is a list of
    ## 12, coords is a list(limits=list(x=NULL,y=NULL)) with class
    ## e.g. c("cartesian","coord"). The result is a transformed data
    ## frame where all the data values are between 0 and 1.
    
    ## 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.

    g$data <- ggplot2:::coord_transform(plistextra$plot$coord, g$data,
                                        plistextra$panel$ranges[[1]])
    plist$geoms[[i]] <- g
  }
  # Export axis specification as a combination of breaks and
  # labels, on the relevant axis scale (i.e. so that it can
  # be passed into d3 on the x axis scale instead of on the 
  # grid 0-1 scale). This allows transformations to be used 
  # out of the box, with no additional d3 coding. 
  theme.pars <- ggplot2:::plot_theme(p)  
  
  ## Flip labels if coords are flipped - transform does not take care
  ## of this. Do this BEFORE checking if it is blank or not, so that
  ## individual axes can be hidden appropriately, e.g. #1.
  ranges <- plistextra$panel$ranges[[1]]
  if("flip"%in%attr(plistextra$plot$coordinates, "class")){
    temp <- plistextra$plot$labels$x
    plistextra$plot$labels$x <- plistextra$plot$labels$y
    plistextra$plot$labels$y <- temp
  }
  is.blank <- function(el.name){
    x <- ggplot2::calc_element(el.name, p$theme)
    "element_blank"%in%attr(x,"class")
  }
  plist$axis <- list()
  for(xy in c("x","y")){
    s <- function(tmp)sprintf(tmp, xy)
    plist$axis[[xy]] <- ranges[[s("%s.major")]]
    plist$axis[[s("%slab")]] <- if(is.blank(s("axis.text.%s"))){
      NULL
    }else{
      ranges[[s("%s.labels")]]
    }
    plist$axis[[s("%srange")]] <- ranges[[s("%s.range")]]
    plist$axis[[s("%sname")]] <- if(is.blank(s("axis.title.%s"))){
      ""
    }else{
      plistextra$plot$labels[[xy]]
    }
    plist$axis[[s("%sline")]] <- !is.blank(s("axis.line.%s"))
    plist$axis[[s("%sticks")]] <- !is.blank(s("axis.ticks.%s"))
  }
  
  plist$legend <- getLegendList(plistextra)
  if(length(plist$legend)>0){
    plist$legend <- plist$legend[which(sapply(plist$legend, function(i) length(i)>0))]
  }  # only pass out legends that have guide = "legend" or guide="colorbar"
  
  # Remove legend if theme has no legend position
  if(theme.pars$legend.position=="none") plist$legend <- NULL
  
  if("element_blank"%in%attr(theme.pars$plot.title, "class")){
    plist$title <- ""
  } else {
    plist$title <- plistextra$plot$labels$title
  }

  pargs <- list()
  for(g in plist$geoms){
    pargs <- c(pargs, g$traces)
  }
  pargs$kwargs <- list()
  pargs
}

#' Convert a layer to a list. Called from gg2list()
#' @param l one layer of the ggplot object
#' @param d one layer of calculated data from ggplot2::ggplot_build(p)
#' @param ranges axes ranges
#' @return list representing a layer, with corresponding aesthetics, ranges, and groups.
#' @export
layer2list <- function(l, d, ranges){
  g <- list(geom=l$geom$objname,
            data=d)
  g$aes <- sapply(l$mapping, function(k) as.character(as.expression(k))) # needed for when group, etc. is an expression

  ## 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.
  g$params <- c(l$geom_params, l$stat_params)
  ## non-ggplot2 params like name are useful for plot.ly and ggplot2
  ## places them into stat_params.
  for(p.name in names(g$params)){
    names(g$params[[p.name]]) <- NULL
  }

  ## Convert complex ggplot2 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.

  ## Every plotly trace has one of these types
  ## type=scatter,bar,box,histogramx,histogram2d,heatmap

  ## for type=scatter, you can define
  ## mode=none,markers,lines,lines+markers where "lines" is the
  ## default for 20 or more points, "lines+markers" is the default for
  ## <20 points. "none" is useful mainly if fill is used to make area
  ## plots with no lines.

  ## marker=list(size,line,color="rgb(54,144,192)",opacity,symbol)

  ## symbol=circle,square,diamond,cross,x,
  ## triangle-up,triangle-down,triangle-left,triangle-right

  geom <- function(...){
    gnames <- c(...)
    g$geom %in% gnames
  }
  g$geom <- if(geom("abline")){
    # "Trick" ggplot coord_transform into transforming the slope and intercept
    g$data[,"x"] <- ranges$x.range[1]
    g$data[,"xend"] <- ranges$x.range[2]
    g$data[,"y"] <- g$data$slope*ranges$x.range[1]+g$data$intercept
    g$data[,"yend"] <-  g$data$slope*ranges$x.range[2]+g$data$intercept
    g$data <- as.data.frame(g$data)
    if(g$aes[["group"]]=="1"){ 
      # ggplot2 defaults to adding a group attribute
      # which misleads for situations where there are 
      # multiple lines with the same group. 
      # if the group attribute conveys no additional 
      # information, remove it.
      ## TODO: Figure out a better way to handle this...
      g$aes <- g$aes[-which(names(g$aes)=="group")]
    } 
    "segment"
  } else if(geom("point")){
    g$data$group <- 1
    # Fill set to match ggplot2 default of filled in circle. 
    if(!"fill"%in%names(g$data) & "colour"%in%names(g$data)){
      g$data[["fill"]] <- g$data[["colour"]]
    }
    "point"
  } else if(geom("ribbon")){
    # Color set to match ggplot2 default of fill with no outside border.
    if("fill"%in%names(g$data) & !"colour"%in%names(g$data)){
      g$data[["colour"]] <- g$data[["fill"]]
    }
    "ribbon"
  } else if(geom("density") | geom("area")){
    "ribbon"
  } else if(geom("tile") | geom("raster") | geom("histogram") ){
    # Color set to match ggplot2 default of tile with no outside border.
    if(!"colour"%in%names(g$data) & "fill"%in%names(g$data)){
      g$data[["colour"]] <- g$data[["fill"]]
      # Make outer border of 0 size if size isn't already specified.
      if(!"size"%in%names(g$data)) g$data[["size"]] <- 0 
    }
    "rect"
  } else if(geom("bar")){
    "rect"
  } else if(g$geom=="bin2d"){
    stop("TODO")
  } else if(geom("boxplot")){
    stop("boxplots are not supported. Workaround: rects, lines, and points")
    ## TODO: boxplot support. But it is hard since boxplots are drawn
    ## using multiple geoms and it is not straightforward to deal with
    ## that using our current JS code. There is a straightforward
    ## workaround: combine working geoms (rects, lines, and points).

    g$data$outliers <- sapply(g$data$outliers, FUN=paste, collapse=" @ ") 
    # outliers are specified as a list... 
  } else if(geom("violin")){
    x <- g$data$x
    vw <- g$data$violinwidth
    xmin <- g$data$xmin
    xmax <- g$data$xmax
    g$data$xminv <- x-vw*(x-xmin)
    g$data$xmaxv <- x+vw*(xmax-x)
    newdata <- ddply(g$data, .(group), function(df){
      rbind(arrange(transform(df, x=xminv), y), arrange(transform(df, x=xmaxv), -y))
                })
    newdata <- ddply(newdata, .(group), function(df) rbind(df, df[1,]))
    g$data <- newdata
    "polygon"
  } else if(geom("step")){
    datanames <- names(g$data)
    g$data <- ddply(g$data, .(group), function(df) ggplot2:::stairstep(df))
    "path"
  } else if(geom("contour") | g$geom=="density2d"){
    g$aes[["group"]] <- "piece"
    "path"
  } else if(geom("freqpoly")){
    "line"
  } else if(geom("quantile")){
    "path"
  } else if(geom("hex")){
    ## TODO: for interactivity we will run into the same problems as
    ## we did with histograms. Again, if we put several
    ## clickSelects/showSelected values in the same hexbin, then
    ## clicking/hiding hexbins doesn't really make sense. Need to stop
    ## with an error if showSelected/clickSelects is used with hex.
    g$aes[["group"]] <- "group"
    dx <- ggplot2::resolution(g$data$x, FALSE)
    dy <- ggplot2::resolution(g$data$y, FALSE) / sqrt(3) / 2 * 1.15
    hex <- as.data.frame(hexcoords(dx, dy))[,1:2]
    hex <- rbind(hex, hex[1,]) # to join hexagon back to first point
    g$data$group <- as.numeric(interaction(g$data$group, 1:nrow(g$data)))
    ## this has the potential to be a bad assumption - 
    ##   by default, group is identically 1, if the user 
    ##   specifies group, polygons aren't possible to plot
    ##   using d3, because group will have a different meaning
    ##   than "one single polygon".
    newdata <- ddply(g$data, .(group), function(df){
      df$xcenter <- df$x
      df$ycenter <- df$y
      cbind(x=df$x+hex$x, y=df$y+hex$y, df[,-which(names(df)%in%c("x", "y"))])
    })
    g$data <- newdata
    # Color set to match ggplot2 default of tile with no outside border.
    if(!"colour"%in%names(g$data) & "fill"%in%names(g$data)){
      g$data[["colour"]] <- g$data[["fill"]]
      # Make outer border of 0 size if size isn't already specified.
      if(!"size"%in%names(g$data)) g$data[["size"]] <- 0 
    }
    "polygon"
  } else if(geom("polygon", "line", "segment")) {
    ## all other geoms are basic, and keep the same name.
    g$geom
  } else {
    stop("unsupported geom ", g$geom)
  }

  ## For ggplot2 polygons, change convert groups to vectors with NA.
  if(geom("polygon")){
    poly.list <- split(g$data, g$data$group)
    is.group <- names(g$data) == "group"
    poly.na.df <- data.frame()
    for(i in seq_along(poly.list)){
      no.group <- poly.list[[i]][,!is.group,drop=FALSE]
      poly.na.df <- rbind(poly.na.df, no.group, NA)
    }
    g$data <- poly.na.df
  }

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

  if(any(g$data$size == 0, na.rm=TRUE)){
    warning(sprintf("geom_%s with size=0 will be invisible",g$geom))
  }

  g$traces <- list()
  group.vars <- c("group",
                  "color", "colour",
                  "fill") #TODO.
  group.var <- NULL
  found.groups <- 0
  for(gv in group.vars){
    if(is.null(group.var)){
      g.col <- g$data[[gv]]
      n.groups <- length(unique(g.col))
      if(n.groups > 1){
        group.var <- g.col
        found.groups <- n.groups
      }
    }
  }
  group.list <- if(found.groups){
    split(g$data, group.var)
  }else{
    list(g$data)
  }
  for(group.i in seq_along(group.list)){
    group.data <- group.list[[group.i]]
    tr <- group2trace(group.data, g$params, g$geom)
    if(is.null(tr$name)){
      tr$name <- group.data$name
    }
    tr$name <- as.character(tr$name[1])
    g$traces[[group.i]] <- tr
  }
  g
}

getMarker <- function(df, params, aesConverter, defaults, only=NULL){
  marker <- list()
  for(name in names(aesConverter)){
    plotly.name <- aesConverter[[name]]
    take.from <- if(name %in% names(params)){
      params
    } else if(name %in% names(df)){
      df
    } else {
      defaults
    }
    take.from <- as.list(take.from)
    to.write <- take.from[[name]]
    ## if(is.null(to.write)){
    ##   print(take.from)
    ##   stop("undefined marker ", name)
    ## }
    marker[[plotly.name]] <- if(!is.null(only)){
      to.write[only]
    }else{
      to.write
    }
  }
  if(length(marker$size) > 1){
    marker$sizeref <- min(marker$size)
    marker$sizemode <- "area"
  }
  if("dash" %in% names(marker)){
    marker$dash <- lty2dash[[marker$dash]]
  }
  marker
}

##' Convert 1 ggplot2 group to 1 plotly trace.
##' @param df data.frame.
##' @param params list of defaults.
##' @param geom length 1 character.
##' @return a list to be passed to plotly().
##' @author Toby Dylan Hocking
group2trace <- function(df, params, geom){
  ## Add plotly type/mode info based on geom type.
  tr <- if(geom == "point"){
    marker <- getMarker(df, params, aes2marker, marker.defaults)
    list(type="scatter",
         mode="markers",
         marker=marker)
  }else if(geom %in% c("line", "polygon")){
    list(type="scatter",
         mode="lines",
         line=getMarker(df, params, aes2line, line.defaults, 1))
  }else{
    stop("group2trace does not support geom ", geom)
  }
  ## Copy data to output trace
  for(name in c("x", "y", "text", "name")){
    take.from <- if(name %in% names(df)){
      df
    }else if(name %in% names(params)){
      params
    }
    tr[[name]] <- take.from[[name]]
  }
  tr
}
#' Get legend information.
#' @param plistextra output from ggplot2::ggplot_build(p)
#' @return list containing information for each legend
#' @export
getLegendList <- function(plistextra){
  plot <- plistextra$plot
  scales <- plot$scales
  layers <- plot$layers
  default_mapping <- plot$mapping
  theme <- ggplot2:::plot_theme(plot)
  position <- theme$legend.position
  # by default, guide boxes are vertically aligned
  theme$legend.box <- if(is.null(theme$legend.box)) "vertical" else theme$legend.box
  
  # size of key (also used for bar in colorbar guide)
  theme$legend.key.width <- if(is.null(theme$legend.key.width)) theme$legend.key.size
  theme$legend.key.height <- if(is.null(theme$legend.key.height)) theme$legend.key.size
  # by default, direction of each guide depends on the position of the guide.
  theme$legend.direction <- if(is.null(theme$legend.direction)){
    if (length(position) == 1 && position %in% c("top", "bottom", "left", "right"))
      switch(position[1], top =, bottom = "horizontal", left =, right = "vertical")
    else
      "vertical"
  }
  # justification of legend boxes
  theme$legend.box.just <-
    if(is.null(theme$legend.box.just)) {
      if (length(position) == 1 && position %in% c("top", "bottom", "left", "right"))
        switch(position, bottom =, top = c("center", "top"), left =, right = c("left", "top"))
      else
        c("center", "center")
    } 
  
  position <- theme$legend.position
  guides <- plyr::defaults(plot$guides, guides(colour="legend", fill="legend"))
  labels <- plot$labels
  gdefs <- ggplot2:::guides_train(scales = scales, theme = theme, guides = guides, labels = labels)
  if (length(gdefs) != 0) {
    gdefs <- ggplot2:::guides_merge(gdefs)
    gdefs <- ggplot2:::guides_geom(gdefs, layers, default_mapping)
  } else (ggplot2:::zeroGrob())
  names(gdefs) <- sapply(gdefs, function(i) i$title)
  lapply(gdefs, getLegend)
}

#' Function to get legend information for each scale
#' @param mb single entry from ggplot2:::guides_merge() list of legend data
#' @return list of legend information, NULL if guide=FALSE.
getLegend <- function(mb){
  guidetype <- mb$name
  ## The main idea of legends:
  
  ## 1. Here in getLegend I export the legend entries as a list of
  ## rows that can be used in a data() bind in D3.

  ## 2. In add_legend in the JS code I create a <table> for every
  ## legend, and then I bind the legend entries to <tr>, <td>, and
  ## <svg> elements.
  geoms <- sapply(mb$geoms, function(i) i$geom$objname)
  cleanData <- function(data, key, geom, params){
    if(nrow(data)==0) return(data.frame()); # if no rows, return an empty df.
    if("guide"%in%names(params)){
      if(params[["guide"]]=="none") return(data.frame()); # if no guide, return an empty df
    } 
    data$order <- 1:nrow(data)
    data <- merge(data, key)
    data <- data[order(data$order),]
    if(!".label"%in%names(data)) return(data.frame()); # if there are no labels, return an empty df.
    if(nrow(data)==0) return(data.frame());
    data <- data[,which(colSums(!is.na(data))>0)] # remove cols that are entirely na
    if("colour"%in%names(data)) data[["colour"]] <- toRGB(data[["colour"]]) # color hex values
    if("fill"%in%names(data)) data[["fill"]] <- toRGB(data[["fill"]]) # fill hex values
    names(data) <- paste(geom, names(data), sep="") # aesthetics by geom
    names(data) <- gsub(paste(geom, ".", sep=""), "", names(data), fixed=TRUE) # label isn't geom-specific
    data
  }
  dataframes <- lapply(mb$geoms, function(i) cleanData(i$data, mb$key, i$geom$objname, i$params))
  dataframes <- dataframes[which(sapply(dataframes, nrow)>0)]
  # Check to make sure datframes is non-empty. If it is empty, return NULL.
  if(length(dataframes)>0) {
    data <- merge_recurse(dataframes)
  } else return(NULL)
  data <- lapply(nrow(data):1, function(i) as.list(data[i,]))
  if(guidetype=="none"){
    NULL
  } else{
    list(guide = guidetype, 
         geoms = geoms, 
         title = mb$title, 
         entries = data)
  }
}

#' Convert R colors to RGB hexadecimal color values
#' @param x character
#' @return hexadecimal color value (if is.na(x), return "none" for compatibility with JavaScript)
#' @export
toRGB <- function(x){
  rgb.matrix <- col2rgb(x)
  rgb.text <- apply(rgb.matrix, 2, paste, collapse=",")
  rgb.css <- sprintf("rgb(%s)", rgb.text)
  ifelse(is.na(x), "none", rgb.css)
}

#' Function to merge a list of data frames (from the reshape package)
#' @param dfs list of data frames
#' @param ... other arguments to merge
#' @return data frame of merged lists
merge_recurse = function (dfs, ...) 
{
  if (length(dfs) == 1) {
    dfs[[1]]
  }
  else if (length(dfs) == 2) {
    merge(dfs[[1]], dfs[[2]], all.x = TRUE, sort = FALSE, ...)
  }
  else {
    merge(dfs[[1]], Recall(dfs[-1]), all.x = TRUE, sort = FALSE, 
          ...)
  }
}
tdhock/ggplotly documentation built on May 31, 2019, 7:36 a.m.