Skip to content

Magic scale labeller #208

Closed
Closed

Description

@paleolimbot

@hadley mentioned the idea of magic scale defaults, particularly for medium-sized numbers that look nice with scales::comma(). This is a just reprex to play with this idea (seemed more suited to here than an issue in ggplot2).

library(ggplot2)
library(scales)
library(rlang)

conditional_labeller <- function(..., .env = caller_env()) {
  dots <- quos(...)
  force(.env)
  function(breaks) {
    eval_tidy(
      quo(dplyr::case_when(!!!dots)), 
      data = list(. = breaks), env = .env
    )
  }
}

label_magic <- conditional_labeller(
  # range with big but not too big numbers
  any(abs(.) >= 1e3) && all(abs(.) <= 1e7) ~ comma(.),
  # range with really big numbers 
  any(abs(.) > 1e7) ~ scientific(.),
  # range with really small numbers
  any(abs(.) < 1e-3 & abs(.) != 0) ~ scientific(.),
  # all other ranges
  TRUE ~ format(., trim = TRUE)
)

ggrange <- function(low = 0, high = 1, labels = label_magic) {
  ggplot() + 
    geom_blank(aes(x = c(low, high), y = c(low, high))) +
    scale_x_continuous(labels = labels) +
    scale_y_continuous(labels = labels)
}

ggrange(0, 1e7)

ggrange(0, 1e10)

ggrange(0, 1e-4)

ggrange(0, 500)

ggrange(0.05, 0.10)

Created on 2019-05-15 by the reprex package (v0.2.1)

Activity

  1. changed the title [-]Magic scales[/-] [+]Magic scale labeller[/+] on May 15, 2019
  2. hadley commented on May 15, 2019

    @hadley
    Member

    Could you rewrite with a little less indirection? I think your solution is mostly equivalent to this:

    label_auto <- function(x) {
      min <- min(abs(x))
      max <- max(abs(x))
      
      if (min > 1e7) {
        scientific(x)
      } else if (min > 1e3) {
        comma(x)
      } else {
        format(x, trim = TRUE)
      }
    )

    (I think using min() and max() instead of any() and all() makes it a bit easier to reason about, but the fact that I didn't use max() makes me suspicious that I haven't correctly captured your algorithm)

  3. paleolimbot commented on May 15, 2019

    @paleolimbot
    Author

    Thanks, it's much clearer that way. I ended up having to use max() to make sure that breaks that start small and go huge don't use comma().

    library(scales)
    library(ggplot2)
    
    label_auto <- function(x) {
      max_magnitude <- max(abs(x[x != 0]))
      min_magnitude <- min(abs(x[x != 0]))
    
      if (max_magnitude > 1e7 || min_magnitude < 1e-3) {
        scientific(x)
      } else if (min_magnitude > 1e3) {
        comma(x)
      } else {
        format(x, trim = TRUE)
      }
    }
    
    df <- data.frame(
      really_big = c(0, 1e9), 
      kind_of_big = c(0, 1e7), 
      really_small = c(0, 1e-4),
      other = c(0, 1)
    )
    
    # default labels
    (p <- 
        ggplot(df, aes(other, really_small, col = kind_of_big, fill = really_big)) + 
        geom_point())

    # auto labels
    p + 
      scale_x_continuous(labels = label_auto) +
      scale_y_continuous(labels = label_auto) +
      scale_color_continuous(labels = label_auto) +
      scale_fill_continuous(labels = label_auto)

    Created on 2019-05-15 by the reprex package (v0.2.1)

  4. hadley commented on May 15, 2019

    @hadley
    Member

    Is there any harm in using comma() where you currently use format()?

    I worry about about too many digits — I think you might also need to test some small ranges, like c(0, 0.000001) (and just showing the results of the function as character vectors would be adequate, I think)

  5. paleolimbot commented on May 15, 2019

    @paleolimbot
    Author

    A few more thoughts:

    comma() rounds by default, which makes it hard to use generally for small ish numbers:

    library(scales)
    comma(0.2)
    #> [1] "0"
    format(0.2, trim = TRUE)
    #> [1] "0.2"

    scientific() also has a similar rounding issue for large numbers that are very close together:

    library(scales)
    scientific(c(999999, 1000000))
    #> [1] "1e+06" "1e+06"
    format(c(999999, 1000000), scientific = TRUE)
    #> [1] "9.99999e+05" "1.00000e+06"

    It may be easier to understand if all the labels had the same exponent (except in the case of transformed scales). Some plotting programs (matlab does this I think) do scientific notation by default with pretty superscripts (i.e. expression(1.0 %.% 10^6)).

    This new-and-improved version also does zero-length input (which was previously very noisy), and uses max_magnitude to detect numbers that should be comma-ed (as in c(999, 1000)). I also dropped the cutoff for scientific notation to 1e6 to help with too many digits. I wonder if this will be annoying for the common case of years on the x-axis (when 1999 should probably not be 1,999).

    library(scales)
    label_auto <- function(x) {
      if (length(x) == 0) return(character(0))
      
      max_magnitude <- max(abs(x[x != 0]))
      min_magnitude <- min(abs(x[x != 0]))
      
      if (max_magnitude > 1e6 || min_magnitude < 1e-3) {
        format(x, scientific = TRUE)
      } else if (max_magnitude > 1e3) {
        comma(x)
      } else {
        format(x, trim = TRUE)
      }
    }
    
    b <- function(a, b) extended_breaks(3)(c(a, b))
    
    label_auto(numeric(0))
    #> character(0)
    label_auto(b(0, 1e7))
    #> [1] "0e+00" "5e+06" "1e+07"
    label_auto(b(0, 1e-4))
    #> [1] "0e+00" "5e-05" "1e-04"
    label_auto(b(0, 1e6))
    #> [1] "0"         "500,000"   "1,000,000"
    label_auto(b(0, 1))
    #> [1] "0.0" "0.5" "1.0"
    
    # tricker breaks
    label_auto(c(0, 0.000001))
    #> [1] "0e+00" "1e-06"
    label_auto(c(0.0009, 0.0010, 0.0011))
    #> [1] "9.0e-04" "1.0e-03" "1.1e-03"
    label_auto(c(999, 1000, 1001))
    #> [1] "999"   "1,000" "1,001"
    label_auto(c(999999, 1000000, 1000001))
    #> [1] "9.999990e+05" "1.000000e+06" "1.000001e+06"
  6. paleolimbot commented on May 16, 2019

    @paleolimbot
    Author

    pillar does some cool stuff here too:

    library(scales)
    b <- function(a, b) extended_breaks(3)(c(a, b))
    label_auto <- pillar::pillar_shaft
    
    label_auto(numeric(0))
    #> character(0)
    label_auto(b(0, 1e15))
    #> 0.     
    #> 5.00e14
    #> 1.00e15
    label_auto(b(0, 1e-10))
    #> 0.      
    #> 5.00e-11
    #> 1.00e-10
    label_auto(b(0, 1e6))
    #>       0
    #>  500000
    #> 1000000
    label_auto(b(0, 1))
    #> 0  
    #> 0.5
    #> 1
    
    # tricker breaks
    label_auto(c(0, 0.000001))
    #> 0       
    #> 0.000001
    label_auto(c(0.0009, 0.0010, 0.0011))
    #> 0.0009
    #> 0.001 
    #> 0.0011
    label_auto(c(999, 1000, 1001))
    #>  999
    #> 1000
    #> 1001
    label_auto(c(999999, 1000000, 1000001))
    #>  999999
    #> 1000000
    #> 1000001
  7. hadley commented on May 16, 2019

    @hadley
    Member

    For label_auto(c(999999, 1000000, 1000001)) maybe we should call format() with scientific = TRUE and scientific = FALSE and pick the shortest?

    The constraints are a little different than for pillar since I think we always want to show the same number of decimal places.

  8. paleolimbot commented on May 20, 2019

    @paleolimbot
    Author

    Here's one way that might look...it's slightly different for huge and tiny numbers since we might want to pick between commas and scientific for the big ones, and format() with scientific = TRUE and scientific = FALSE for the tiny ones.

    library(scales)
    
    format_shortest <- function(breaks, ...) {
      options <- list(...)
      labels <- lapply(options, function(labeller) labeller(breaks))
      lengths <- vapply(labels, function(label) max(nchar(label)), integer(1))
      labels[[which.min(lengths)]]
    }
    
    label_auto <- function(x) {
      if (length(x) == 0) return(character(0))
      
      max_magnitude <- max(abs(x[x != 0]))
      min_magnitude <- min(abs(x[x != 0]))
      
      if (max_magnitude > 1e6) {
        format_shortest(
          x, 
          comma,
          function(breaks) format(breaks, scientific = TRUE, trim = TRUE)
        )
      } else if (min_magnitude < 1e-3) {
        format_shortest(
          x, 
          function(breaks) format(breaks, scientific = FALSE, trim = TRUE), 
          function(breaks) format(breaks, scientific = TRUE, trim = TRUE)
        )
      } else if (max_magnitude > 1e3) {
        comma(x)
      } else {
        format(x, trim = TRUE)
      }
    }
    
    b <- function(a, b) extended_breaks(3)(c(a, b))
    
    label_auto(numeric(0))
    #> character(0)
    label_auto(b(0, 1e7))
    #> [1] "0e+00" "5e+06" "1e+07"
    label_auto(b(0, 1e-4))
    #> [1] "0e+00" "5e-05" "1e-04"
    label_auto(b(0, 1e6))
    #> [1] "0"         "500,000"   "1,000,000"
    label_auto(b(0, 1))
    #> [1] "0.0" "0.5" "1.0"
    
    # tricker breaks
    label_auto(c(0, 0.000001))
    #> [1] "0e+00" "1e-06"
    label_auto(c(0.0009, 0.0010, 0.0011))
    #> [1] "0.0009" "0.0010" "0.0011"
    label_auto(c(0.00009, 0.00010, 0.00011))
    #> [1] "0.00009" "0.00010" "0.00011"
    label_auto(c(0.000009, 0.000010, 0.000011))
    #> [1] "9.0e-06" "1.0e-05" "1.1e-05"
    
    label_auto(c(999, 1000, 1001))
    #> [1] "999"   "1,000" "1,001"
    label_auto(c(999999, 1000000, 1000001))
    #> [1] "999,999"   "1,000,000" "1,000,001"
    label_auto(c(9999999, 10000000, 10000001))
    #> [1] "9,999,999"  "10,000,000" "10,000,001"
    label_auto(c(99999999, 100000000, 100000001))
    #> [1] "1e+08" "1e+08" "1e+08"
    label_auto(c(0.0000009, 0.0000010, 0.0000011))
    #> [1] "9.0e-07" "1.0e-06" "1.1e-06"

    Created on 2019-05-20 by the reprex package (v0.2.1)

  9. hadley commented on May 20, 2019

    @hadley
    Member

    @dpseidel can you think of other cases that we need to test for this? All I remember is that I usually forget some important edge case of format(), and we might've encountered this last year.

  10. dpseidel commented on May 20, 2019

    @dpseidel
    Collaborator

    The only thing that comes to mind immediately is that I know we debated the proper handling of NA, NULL, & Inf in regards to format functions last summer. As it stands this latest iteration of label_auto errors if passed an NA.

  11. paleolimbot commented on May 21, 2019

    @paleolimbot
    Author

    Good call! Here's one go at handling NA, NULL, and Inf:

    library(scales)
    
    format_shortest <- function(breaks, ...) {
      options <- list(...)
      labels <- lapply(options, function(labeller) labeller(breaks))
      lengths <- vapply(labels, function(label) max(nchar(label)), integer(1))
      labels[[which.min(lengths)]]
    }
    
    label_auto <- function(x) {
      if (length(x) == 0) return(character(0))
      if (sum(is.finite(x)) == 0) return(format(x, trim = TRUE))
      
      max_magnitude <- max(abs(x[x != 0 & is.finite(x)]))
      min_magnitude <- min(abs(x[x != 0 & is.finite(x)]))
      
      if (max_magnitude > 1e6) {
        format_shortest(
          x, 
          comma,
          function(breaks) format(breaks, scientific = TRUE, trim = TRUE)
        )
      } else if (min_magnitude < 1e-3) {
        format_shortest(
          x, 
          function(breaks) format(breaks, scientific = FALSE, trim = TRUE), 
          function(breaks) format(breaks, scientific = TRUE, trim = TRUE)
        )
      } else if (max_magnitude > 1e3) {
        comma(x)
      } else {
        format(x, trim = TRUE)
      }
    }
    
    b <- function(a, b) extended_breaks(3)(c(a, b))
    
    label_auto(NULL)
    #> character(0)
    label_auto(NA)
    #> [1] "NA"
    label_auto(Inf)
    #> [1] "Inf"
    label_auto(c(NA, Inf, -Inf, 1e7))
    #> [1] "NA"    "Inf"   "-Inf"  "1e+07"
    label_auto(c(NA, Inf, -Inf, 1e4))
    #> [1] "NA"     "Inf"    "-Inf"   "10,000"
    label_auto(c(NA, Inf, -Inf, 10))
    #> [1] "NA"   "Inf"  "-Inf" "10"
    label_auto(c(NA, Inf, -Inf, 1e-3))
    #> [1] "NA"    "Inf"   "-Inf"  "0.001"
    label_auto(c(NA, Inf, -Inf, 1e-6))
    #> [1] "NA"    "Inf"   "-Inf"  "1e-06"
    
    label_auto(numeric(0))
    #> character(0)
    label_auto(b(0, 1e7))
    #> [1] "0e+00" "5e+06" "1e+07"
    label_auto(b(0, 1e-4))
    #> [1] "0e+00" "5e-05" "1e-04"
    label_auto(b(0, 1e6))
    #> [1] "0"         "500,000"   "1,000,000"
    label_auto(b(0, 1))
    #> [1] "0.0" "0.5" "1.0"
    
    # tricker breaks
    label_auto(c(0, 0.000001))
    #> [1] "0e+00" "1e-06"
    label_auto(c(0.0009, 0.0010, 0.0011))
    #> [1] "0.0009" "0.0010" "0.0011"
    label_auto(c(0.00009, 0.00010, 0.00011))
    #> [1] "0.00009" "0.00010" "0.00011"
    label_auto(c(0.000009, 0.000010, 0.000011))
    #> [1] "9.0e-06" "1.0e-05" "1.1e-05"
    
    label_auto(c(999, 1000, 1001))
    #> [1] "999"   "1,000" "1,001"
    label_auto(c(999999, 1000000, 1000001))
    #> [1] "999,999"   "1,000,000" "1,000,001"
    label_auto(c(9999999, 10000000, 10000001))
    #> [1] "9,999,999"  "10,000,000" "10,000,001"
    label_auto(c(99999999, 100000000, 100000001))
    #> [1] "1e+08" "1e+08" "1e+08"
    label_auto(c(0.0000009, 0.0000010, 0.0000011))
    #> [1] "9.0e-07" "1.0e-06" "1.1e-06"

    Created on 2019-05-21 by the reprex package (v0.2.1)

  12. hadley commented on Oct 25, 2019

    @hadley
    Member

    @paleolimbot do you have any more thoughts on this function? Do you think it's fine to add to scales, at least as a first attempt at solving the problem?

  13. paleolimbot commented on Oct 25, 2019

    @paleolimbot
    Author

    I haven't thought about this in a long time, but I think it's ready for scales, perhaps marked as experimental. The only concern I have based on the above output is that it's super common to have year on an axis, and label_auto() would make those labels be "2,019".

    Is this a PR you were hoping I would do? I would be happy to do it (or not) but it might take me a week or more to get to it.

  14. hadley commented on Oct 25, 2019

    @hadley
    Member

    I'm happy to do the PR.

    I guess it should be called auto_format()?

  15. paleolimbot commented on Oct 25, 2019

    @paleolimbot
    Author

    I think so - all the other ones are *_format() with a corresponding (*()) function (e.g., (comma_format() and comma()). Having an auto() function seems sub-optimal though.

  16. hadley commented on Oct 25, 2019

    @hadley
    Member

    We could also do number_auto() to match the newer number_si().

  17. paleolimbot commented on Oct 26, 2019

    @paleolimbot
    Author

    Looks great! Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions