diff --git a/DESCRIPTION b/DESCRIPTION index 02d5d246b4..c44e822b8b 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: plotly Title: Create Interactive Web Graphics via 'plotly.js' -Version: 3.4.3 +Version: 3.4.4 Authors@R: c(person("Carson", "Sievert", role = c("aut", "cre"), email = "cpsievert1@gmail.com"), person("Chris", "Parmer", role = c("aut", "cph"), diff --git a/NEWS b/NEWS index a1c68c8b93..22f4b857c8 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,9 @@ +3.4.4 -- 17 Mar 2016 + +BUGFIX: + +Show discrete positional values in tooltip (see #515); better GeomTile conversion; pass plot object into layers2traces. + 3.4.3 -- 14 Mar 2016 BUGFIX: diff --git a/R/ggplotly.R b/R/ggplotly.R index 62e50e820a..7670ec5489 100644 --- a/R/ggplotly.R +++ b/R/ggplotly.R @@ -73,6 +73,13 @@ gg2list <- function(p, width = NULL, height = NULL, tooltip = "all", source = "A scale_x <- function() scales$get_scales("x") scale_y <- function() scales$get_scales("y") panel <- ggfun("train_position")(panel, data, scale_x(), scale_y()) + # Before mapping x/y position, save the domain (for discrete scales) + # to display in tooltip. + data <- lapply(data, function(d) { + if (!is.null(scale_x()) && scale_x()$is_discrete()) d$x_plotlyDomain <- d$x + if (!is.null(scale_y()) && scale_y()$is_discrete()) d$y_plotlyDomain <- d$y + d + }) data <- ggfun("map_position")(panel, data, scale_x(), scale_y()) # for some geoms (e.g. boxplots) plotly.js needs the "pre-statistics" data prestats_data <- data @@ -187,21 +194,18 @@ gg2list <- function(p, width = NULL, height = NULL, tooltip = "all", source = "A # stat specific mappings grep("^\\.\\.", as.character(x$stat$default_aes), value = TRUE) ) - # remove leading/trailing dots in "hidden" stat aes - map <- sub("^\\.\\.", "", sub("\\.\\.$", "", map)) + # "hidden" names should be taken verbatim + idx <- grepl("^\\.\\.", map) & grepl("\\.\\.$", map) + hiddenMap <- sub("^\\.\\.", "", sub("\\.\\.$", "", map)) + map[idx] <- hiddenMap[idx] + names(map)[idx] <- hiddenMap[idx] if (!identical(tooltip, "all")) { map <- map[tooltip] } - # tooltips for discrete positional scales are misleading - if (scales$get_scales("x")$is_discrete()) { - map <- map[!names(map) %in% "x"] - } - if (scales$get_scales("y")$is_discrete()) { - map <- map[!names(map) %in% "y"] - } map }) + # attach a new column (hovertext) to each layer of data that should get mapped # to the text trace property data <- Map(function(x, y) { @@ -234,10 +238,11 @@ gg2list <- function(p, width = NULL, height = NULL, tooltip = "all", source = "A x }, data, aesMap) + + # layers -> plotly.js traces - traces <- layers2traces( - data, prestats_data, layers, panel$layout, scales, p$labels - ) + traces <- layers2traces(data, prestats_data, panel$layout, p) + # default to just the text in hover info, mainly because of this # https://github.com/plotly/plotly.js/issues/320 traces <- lapply(traces, function(tr) { diff --git a/R/layers2traces.R b/R/layers2traces.R index 899e941886..0ea3b0d53f 100644 --- a/R/layers2traces.R +++ b/R/layers2traces.R @@ -1,14 +1,14 @@ # layer -> trace conversion -layers2traces <- function(data, prestats_data, layers, layout, scales, labels) { +layers2traces <- function(data, prestats_data, layout, p) { # Attach a "geom class" to each layer of data for method dispatch - data <- Map(function(x, y) prefix_class(x, class(y$geom)[1]), data, layers) + data <- Map(function(x, y) prefix_class(x, class(y$geom)[1]), data, p$layers) # Extract parameters for each layer - params <- lapply(layers, function(x) { + params <- lapply(p$layers, function(x) { c(x$geom_params, x$stat_params, x$aes_params, position = ggtype(x, "position")) }) # we draw legends only for discrete scales discreteScales <- list() - for (sc in scales$non_position_scales()$scales) { + for (sc in p$scales$non_position_scales()$scales) { if (sc$is_discrete()) { discreteScales[[sc$aesthetics]] <- sc } @@ -22,7 +22,7 @@ layers2traces <- function(data, prestats_data, layers, layout, scales, labels) { for (i in seq_along(data)) { # This has to be done in a loop, since some layers are really two layers, # (and we need to replicate the data/params in those cases) - d <- to_basic(data[[i]], prestats_data[[i]], layout, params[[i]]) + d <- to_basic(data[[i]], prestats_data[[i]], layout, params[[i]], p) if (is.data.frame(d)) d <- list(d) for (j in seq_along(d)) { datz <- c(datz, d[j]) @@ -52,7 +52,7 @@ layers2traces <- function(data, prestats_data, layers, layout, scales, labels) { if (all(is.na(fac))) fac <- 1 dl <- split(d, fac, drop = TRUE) # list of traces for this layer - trs <- Map(geom2trace, dl, paramz[i]) + trs <- Map(geom2trace, dl, paramz[i], list(p)) # are we splitting by a discrete scale on this layer? # if so, set name/legendgroup/showlegend isDiscrete <- names(d) %in% paste0(names(discreteScales), "_plotlyDomain") @@ -104,14 +104,15 @@ layers2traces <- function(data, prestats_data, layers, layout, scales, labels) { #' @param prestats_data the data before statistics are computed. #' @param layout the panel layout. #' @param params parameters for the geom, statistic, and 'constant' aesthetics +#' @param p a ggplot2 object (the conversion may depend on scales, for instance). #' @param ... currently ignored #' @export -to_basic <- function(data, prestats_data, layout, params, ...) { +to_basic <- function(data, prestats_data, layout, params, p, ...) { UseMethod("to_basic") } #' @export -to_basic.GeomViolin <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomViolin <- function(data, prestats_data, layout, params, p, ...) { n <- nrow(data) revData <- data[order(data$y, decreasing = TRUE), ] idx <- !names(data) %in% c("x", "xmin", "xmax") @@ -125,7 +126,7 @@ to_basic.GeomViolin <- function(data, prestats_data, layout, params, ...) { } #' @export -to_basic.GeomBoxplot <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomBoxplot <- function(data, prestats_data, layout, params, p, ...) { aez <- names(GeomBoxplot$default_aes) for (i in aez) { prestats_data[[i]] <- NULL @@ -138,7 +139,7 @@ to_basic.GeomBoxplot <- function(data, prestats_data, layout, params, ...) { } #' @export -to_basic.GeomSmooth <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomSmooth <- function(data, prestats_data, layout, params, p, ...) { dat <- prefix_class(data, "GeomPath") dat$alpha <- NULL if (!identical(params$se, FALSE)) { @@ -150,33 +151,33 @@ to_basic.GeomSmooth <- function(data, prestats_data, layout, params, ...) { } #' @export -to_basic.GeomRibbon <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomRibbon <- function(data, prestats_data, layout, params, p, ...) { prefix_class(ribbon_dat(data), "GeomPolygon") } #' @export -to_basic.GeomArea <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomArea <- function(data, prestats_data, layout, params, p, ...) { prefix_class(ribbon_dat(data), "GeomPolygon") } #' @export -to_basic.GeomDensity <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomDensity <- function(data, prestats_data, layout, params, p, ...) { prefix_class(ribbon_dat(data), "GeomPolygon") } #' @export -to_basic.GeomLine <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomLine <- function(data, prestats_data, layout, params, p, ...) { data <- data[order(data$x), ] prefix_class(data, "GeomPath") } #' @export -to_basic.GeomStep <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomStep <- function(data, prestats_data, layout, params, p, ...) { prefix_class(data, "GeomPath") } #' @export -to_basic.GeomSegment <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomSegment <- function(data, prestats_data, layout, params, p, ...) { # Every row is one segment, we convert to a line with several # groups which can be efficiently drawn by adding NA rows. data$group <- seq_len(nrow(data)) @@ -189,7 +190,7 @@ to_basic.GeomSegment <- function(data, prestats_data, layout, params, ...) { } #' @export -to_basic.GeomRect <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomRect <- function(data, prestats_data, layout, params, p, ...) { data$group <- seq_len(nrow(data)) others <- data[!names(data) %in% c("xmin", "ymin", "xmax", "ymax")] data <- with(data, { @@ -202,7 +203,7 @@ to_basic.GeomRect <- function(data, prestats_data, layout, params, ...) { } #' @export -to_basic.GeomMap <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomMap <- function(data, prestats_data, layout, params, p, ...) { common <- intersect(data$map_id, params$map$id) data <- data[data$map_id %in% common, , drop = FALSE] map <- params$map[params$map$id %in% common, , drop = FALSE] @@ -216,26 +217,24 @@ to_basic.GeomMap <- function(data, prestats_data, layout, params, ...) { } #' @export -to_basic.GeomRaster <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomRaster <- function(data, prestats_data, layout, params, p, ...) { data <- prefix_class(data, "GeomTile") to_basic(data, prestats_data, layout, params) } #' @export -to_basic.GeomTile <- function(data, prestats_data, layout, params, ...) { - # geom2trace.GeomTile is a heatmap, which requires continuous fill and - # a complete grid - g <- expand.grid(unique(data$x), unique(data$y)) - if (nrow(g) != nrow(data) || is.discrete(prestats_data$fill)) { +to_basic.GeomTile <- function(data, prestats_data, layout, params, p, ...) { + # geom2trace.GeomTile is a heatmap, which requires continuous fill + if (is.discrete(prestats_data$fill)) { data <- prefix_class(data, "GeomRect") - to_basic(data, prestats_data, layout, params) + to_basic(data, prestats_data, layout, params, p) } else { data } } #' @export -to_basic.GeomHex <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomHex <- function(data, prestats_data, layout, params, p, ...) { # see ggplot2:::hexGrob dx <- resolution(data$x, FALSE) dy <- resolution(data$y, FALSE)/sqrt(3)/2 * 1.15 @@ -252,13 +251,13 @@ to_basic.GeomHex <- function(data, prestats_data, layout, params, ...) { } #' @export -to_basic.GeomContour <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomContour <- function(data, prestats_data, layout, params, p, ...) { if (!"fill" %in% names(data)) data$fill <- NA prefix_class(data, "GeomPath") } #' @export -to_basic.GeomDensity2d <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomDensity2d <- function(data, prestats_data, layout, params, p, ...) { if ("hovertext" %in% names(data)) { data$hovertext <- paste0(data$hovertext, "
") } @@ -268,7 +267,7 @@ to_basic.GeomDensity2d <- function(data, prestats_data, layout, params, ...) { } #' @export -to_basic.GeomAbline <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomAbline <- function(data, prestats_data, layout, params, p, ...) { # ugh, we can't trust the group here data$group <- interaction( data[!grepl("group", names(data)) & !vapply(data, anyNA, logical(1))] @@ -280,7 +279,7 @@ to_basic.GeomAbline <- function(data, prestats_data, layout, params, ...) { } #' @export -to_basic.GeomHline <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomHline <- function(data, prestats_data, layout, params, p, ...) { # ugh, we can't trust the group here data$group <- interaction( data[!grepl("group", names(data)) & !vapply(data, anyNA, logical(1))] @@ -292,7 +291,7 @@ to_basic.GeomHline <- function(data, prestats_data, layout, params, ...) { } #' @export -to_basic.GeomVline <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomVline <- function(data, prestats_data, layout, params, p, ...) { # ugh, we can't trust the group here data$group <- interaction( data[!grepl("group", names(data)) & !vapply(data, anyNA, logical(1))] @@ -304,13 +303,13 @@ to_basic.GeomVline <- function(data, prestats_data, layout, params, ...) { } #' @export -to_basic.GeomJitter <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomJitter <- function(data, prestats_data, layout, params, p, ...) { prefix_class(data, "GeomPoint") } #' @export -to_basic.GeomErrorbar <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomErrorbar <- function(data, prestats_data, layout, params, p, ...) { # width for ggplot2 means size of the entire bar, on the data scale # (plotly.js wants half, in pixels) data <- merge(data, layout, by = "PANEL", sort = FALSE) @@ -320,7 +319,7 @@ to_basic.GeomErrorbar <- function(data, prestats_data, layout, params, ...) { } #' @export -to_basic.GeomErrorbarh <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomErrorbarh <- function(data, prestats_data, layout, params, p, ...) { # height for ggplot2 means size of the entire bar, on the data scale # (plotly.js wants half, in pixels) data <- merge(data, layout, by = "PANEL", sort = FALSE) @@ -330,13 +329,13 @@ to_basic.GeomErrorbarh <- function(data, prestats_data, layout, params, ...) { } #' @export -to_basic.GeomLinerange <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomLinerange <- function(data, prestats_data, layout, params, p, ...) { data$width <- 0 prefix_class(data, "GeomErrorbar") } #' @export -to_basic.GeomPointrange <- function(data, prestats_data, layout, params, ...) { +to_basic.GeomPointrange <- function(data, prestats_data, layout, params, p, ...) { data$width <- 0 list( prefix_class(data, "GeomErrorbar"), @@ -345,7 +344,7 @@ to_basic.GeomPointrange <- function(data, prestats_data, layout, params, ...) { } #' @export -to_basic.default <- function(data, prestats_data, layout, params, ...) { +to_basic.default <- function(data, prestats_data, layout, params, p, ...) { data } @@ -358,18 +357,19 @@ to_basic.default <- function(data, prestats_data, layout, params, ...) { #' #' @param data the data returned by \code{plotly::to_basic}. #' @param params parameters for the geom, statistic, and 'constant' aesthetics +#' @param p a ggplot2 object (the conversion may depend on scales, for instance). #' @export -geom2trace <- function(data, params) { +geom2trace <- function(data, params, p) { UseMethod("geom2trace") } #' @export -geom2trace.GeomBlank <- function(data, params) { +geom2trace.GeomBlank <- function(data, params, p) { list() } #' @export -geom2trace.GeomPath <- function(data, params) { +geom2trace.GeomPath <- function(data, params, p) { data <- group2NA(data) L <- list( x = data$x, @@ -393,7 +393,7 @@ geom2trace.GeomPath <- function(data, params) { } #' @export -geom2trace.GeomPoint <- function(data, params) { +geom2trace.GeomPoint <- function(data, params, p) { shape <- aes2plotly(data, params, "shape") L <- list( x = data$x, @@ -424,7 +424,7 @@ geom2trace.GeomPoint <- function(data, params) { } #' @export -geom2trace.GeomBar <- function(data, params) { +geom2trace.GeomBar <- function(data, params, p) { data$y <- data$ymax - data$ymin # TODO: use xmin/xmax once plotly.js allows explicit bar widths # https://github.com/plotly/plotly.js/issues/80 @@ -448,7 +448,7 @@ geom2trace.GeomBar <- function(data, params) { } #' @export -geom2trace.GeomPolygon <- function(data, params) { +geom2trace.GeomPolygon <- function(data, params, p) { data <- group2NA(data) L <- list( x = data$x, @@ -476,7 +476,7 @@ geom2trace.GeomPolygon <- function(data, params) { } #' @export -geom2trace.GeomBoxplot <- function(data, params) { +geom2trace.GeomBoxplot <- function(data, params, p) { list( x = data$x, y = data$y, @@ -506,7 +506,7 @@ geom2trace.GeomBoxplot <- function(data, params) { #' @export -geom2trace.GeomText <- function(data, params) { +geom2trace.GeomText <- function(data, params, p) { list( x = data$x, y = data$y, @@ -525,26 +525,27 @@ geom2trace.GeomText <- function(data, params) { } #' @export -geom2trace.GeomTile <- function(data, params) { - # make sure order of value make sense before throwing z in matrix - data <- data[order(order(data$x), data$y), ] +geom2trace.GeomTile <- function(data, params, p) { x <- sort(unique(data$x)) y <- sort(unique(data$y)) - fill <- scales::rescale(data$fill_plotlyDomain) - txt <- data$hovertext - # create the colorscale, which should ignore NAs - data <- data[!is.na(fill), ] - o <- data[order(data$fill_plotlyDomain), "fill"] - n <- length(o) - qs <- seq(0, 1, length.out = min(n, 100)) - idx <- o[pmax(1, round(n * qs))] - colorscale <- cbind(qs, idx) + # make sure we're dealing with a complete grid + g <- expand.grid(x = x, y = y) + g$order <- seq_len(nrow(g)) + g <- merge(g, data, by = c("x", "y"), all.x = TRUE) + g <- g[order(g$order), ] + # put fill domain on 0-1 scale for colorscale purposes + g$fill_plotlyDomain <- scales::rescale(g$fill_plotlyDomain) + # create the colorscale + colScale <- unique(g[, c("fill_plotlyDomain", "fill")]) + # colorscale goes crazy if there are NAs + colScale <- colScale[complete.cases(colScale), ] + colScale <- colScale[order(colScale$fill_plotlyDomain), ] list( x = x, y = y, - z = matrix(fill, nrow = length(y), ncol = length(x)), - text = matrix(txt, nrow = length(y), ncol = length(x)), - colorscale = colorscale, + z = matrix(g$fill_plotlyDomain, nrow = length(y), ncol = length(x), byrow = TRUE), + text = matrix(g$hovertext, nrow = length(y), ncol = length(x), byrow = TRUE), + colorscale = setNames(colScale, NULL), type = "heatmap", showscale = FALSE, autocolorscale = FALSE @@ -552,17 +553,17 @@ geom2trace.GeomTile <- function(data, params) { } #' @export -geom2trace.GeomErrorbar <- function(data, params) { +geom2trace.GeomErrorbar <- function(data, params, p) { make_error(data, params, "y") } #' @export -geom2trace.GeomErrorbarh <- function(data, params) { +geom2trace.GeomErrorbarh <- function(data, params, p) { make_error(data, params, "x") } #' @export -geom2trace.default <- function(data, params) { +geom2trace.default <- function(data, params, p) { warning( "geom_", class(data)[1], "() has yet to be implemented in plotly.\n", " If you'd like to see this geom implemented,\n", diff --git a/man/geom2trace.Rd b/man/geom2trace.Rd index aa67d9f22e..9788792cc1 100644 --- a/man/geom2trace.Rd +++ b/man/geom2trace.Rd @@ -4,12 +4,14 @@ \alias{geom2trace} \title{Convert a "basic" geoms to a plotly.js trace.} \usage{ -geom2trace(data, params) +geom2trace(data, params, p) } \arguments{ \item{data}{the data returned by \code{plotly::to_basic}.} \item{params}{parameters for the geom, statistic, and 'constant' aesthetics} + +\item{p}{a ggplot2 object (the conversion may depend on scales, for instance).} } \description{ This function makes it possible to convert ggplot2 geoms that diff --git a/man/to_basic.Rd b/man/to_basic.Rd index 7442826d84..494b83ac83 100644 --- a/man/to_basic.Rd +++ b/man/to_basic.Rd @@ -4,7 +4,7 @@ \alias{to_basic} \title{Convert a geom to a "basic" geom.} \usage{ -to_basic(data, prestats_data, layout, params, ...) +to_basic(data, prestats_data, layout, params, p, ...) } \arguments{ \item{data}{the data returned by \code{ggplot2::ggplot_build()}.} @@ -15,6 +15,8 @@ to_basic(data, prestats_data, layout, params, ...) \item{params}{parameters for the geom, statistic, and 'constant' aesthetics} +\item{p}{a ggplot2 object (the conversion may depend on scales, for instance).} + \item{...}{currently ignored} } \description{ diff --git a/tests/testthat/test-ggplot-heatmap.R b/tests/testthat/test-ggplot-heatmap.R index 34434632a8..8833f32ff4 100644 --- a/tests/testthat/test-ggplot-heatmap.R +++ b/tests/testthat/test-ggplot-heatmap.R @@ -25,7 +25,7 @@ test_that("geom_tile is translated to type=heatmap", { L$data[[1]]$hoverinfo == "text" ) expect_true( - all(grepl("^value: [-]?[0-9]+$", c(L$data[[1]]$text))) + all(grepl("value: [-]?[0-9]+$", c(L$data[[1]]$text))) ) }) @@ -42,4 +42,21 @@ test_that("geom_tile() scale_fill_gradient2()", { L <- save_outputs(p, "heatmap-midpoint") # one trace is for the colorbar expect_equal(length(L$data), 2) + expect_equal(L$data[[1]]$type, "heatmap") }) + +tidy_cor <- function(x) { + co <- as.data.frame(cor(x)) + co$var1 <- row.names(co) + tidyr::gather(co, var2, cor, -var1) +} +d <- tidy_cor(mtcars) +p <- ggplot(d, aes(var1, var2, fill = cor)) + geom_tile() + +test_that("geom_tile() with discrete x/y", { + L <- save_outputs(p, "heatmap-discrete") + # one trace is for the colorbar + expect_equal(length(L$data), 2) + expect_equal(L$data[[1]]$type, "heatmap") +}) + diff --git a/tests/testthat/test-ggplot-tooltip.R b/tests/testthat/test-ggplot-tooltip.R index 7312ead818..13ef4ddbd2 100644 --- a/tests/testthat/test-ggplot-tooltip.R +++ b/tests/testthat/test-ggplot-tooltip.R @@ -30,7 +30,7 @@ test_that("dates are displayed in tooltip properly", { test_that("tooltip argument respects ordering", { p <- qplot(mpg, fill = factor(cyl), data = mtcars, geom = "density") - p <- ggplotly(p, tooltip = c("y", "x")) + p <- ggplotly(p, tooltip = c("density", "x")) info <- plotly_build(p) txt <- strsplit(info$data[[1]]$text, "
") expect_true(all(grepl("^density", sapply(txt, "[[", 1)))) @@ -43,3 +43,17 @@ test_that("can hide x values in tooltip", { l <- plotly_build(p) expect_equal(sum(grepl("cyl", l$data[[1]]$text)), 0) }) + +cars <- ggplot(mtcars, aes(mpg, factor(cyl))) +p <- cars + stat_bin2d(aes(fill = ..density..), binwidth = c(3,1)) + +test_that("geom_tile() displays correct info in tooltip with discrete y", { + L <- save_outputs(p, "heatmap-discrete-tooltip") + expect_equal(length(L$data), 2) + expect_equal(L$data[[1]]$type, "heatmap") + txt <- c(L$data[[1]]$text) + txt <- txt[!is.na(txt)] + # tooltip should show y-values on the _data_ scale + expect_true(all(grepl("factor\\(cyl\\): [4,6,8]", txt))) +}) +