+ - 0:00:00
Notes for current slide
Notes for next slide

Interactive dataviz on the web with R, plotly, and shiny

Carson Sievert
Software Engineer, RStudio

Slides: bit.ly/R_Pharma

1 / 62

Your turn

(1) Open the slides in Firefox: bit.ly/R_Pharma


(2) Go to this address https://rstudio.cloud/project/446255. This hosted RStudio instance contains materials for today's workshop.

  • Login or sign up to RStudio Cloud (it's free)
  • Click "Save a Permanent Copy" to copy the project to your account.


(3) Ask me any question at any time by going to slido.com and enter event code #8464 (or use this link). I'll try to check these questions periodically (upvote questions if you'd like them answered!)

05:00
2 / 62

A minimal bar chart

  • Every plotly chart is powered by plotly.js, plus some extra R/JS magic 🎩 🐰.
  • plot_ly() is designed to give more "R-like" defaults
library(plotly)
plot_ly(x = c("A", "B"), y = c(1, 2))

What actually happens when a plotly object is printed and rendered locally?

3 / 62
4 / 62

Plotly figures are JSON

A Plotly figure is made up of trace(s) and a layout. A trace is a mapping from data to visual.

var trace1 = {
x: ['giraffes', 'orangutans', 'monkeys'],
y: [20, 14, 23],
name: 'SF Zoo',
type: 'bar'
};
var trace2 = {
x: ['giraffes', 'orangutans', 'monkeys'],
y: [12, 18, 29],
name: 'LA Zoo',
type: 'bar'
};
var data = [trace1, trace2];
Plotly.newPlot('myDiv', data);

5 / 62

Now in R, with plot_ly()

In R, use plot_ly() (or ggplotly()) to create a plotly object, then add_trace() to add any trace you want.

plot_ly() %>%
add_trace(
x = c('giraffes', 'orangutans', 'monkeys'),
y = c(20, 14, 23),
name = 'SF Zoo',
type = 'bar'
) %>%
add_trace(
x = c('giraffes', 'orangutans', 'monkeys'),
y = c(12, 18, 29),
name = 'LA Zoo',
type = 'bar'
)

6 / 62

plot_ly() syntax

Use ~ to reference a data column

zoo_dat <- tibble::tibble(
animal = rep(c('giraffes', 'orangutans', 'monkeys'), 2),
count = c(20, 14, 23, 12, 18, 29),
zoo = rep(c('SF Zoo', 'LA Zoo'), each = 3)
)
zoo_dat
#> # A tibble: 6 x 3
#> animal count zoo
#> <chr> <dbl> <chr>
#> 1 giraffes 20 SF Zoo
#> 2 orangutans 14 SF Zoo
#> 3 monkeys 23 SF Zoo
#> 4 giraffes 12 LA Zoo
#> 5 orangutans 18 LA Zoo
#> 6 monkeys 29 LA Zoo
plot_ly(zoo_dat) %>%
add_trace(
x = ~animal,
y = ~count,
name = ~zoo,
type = "bar"
)

7 / 62

plot_ly() syntax

For attributes that require a scalar value (e.g. name), plot_ly() generates one trace per level.

zoo_dat <- tibble::tibble(
animal = rep(c('giraffes', 'orangutans', 'monkeys'), 2),
count = c(20, 14, 23, 12, 18, 29),
zoo = rep(c('SF Zoo', 'LA Zoo'), each = 3)
)
zoo_dat
#> # A tibble: 6 x 3
#> animal count zoo
#> <chr> <dbl> <chr>
#> 1 giraffes 20 SF Zoo
#> 2 orangutans 14 SF Zoo
#> 3 monkeys 23 SF Zoo
#> 4 giraffes 12 LA Zoo
#> 5 orangutans 18 LA Zoo
#> 6 monkeys 29 LA Zoo
plot_ly(zoo_dat) %>%
add_trace(
x = ~animal,
y = ~count,
name = ~zoo,
type = "bar"
)

8 / 62

plot_ly() syntax

The split argument is a more explicit way to split data into multiple traces

zoo_dat <- tibble::tibble(
animal = rep(c('giraffes', 'orangutans', 'monkeys'), 2),
count = c(20, 14, 23, 12, 18, 29),
zoo = rep(c('SF Zoo', 'LA Zoo'), each = 3)
)
zoo_dat
#> # A tibble: 6 x 3
#> animal count zoo
#> <chr> <dbl> <chr>
#> 1 giraffes 20 SF Zoo
#> 2 orangutans 14 SF Zoo
#> 3 monkeys 23 SF Zoo
#> 4 giraffes 12 LA Zoo
#> 5 orangutans 18 LA Zoo
#> 6 monkeys 29 LA Zoo
plot_ly(zoo_dat) %>%
add_trace(
x = ~animal,
y = ~count,
split = ~zoo,
type = "bar"
)

9 / 62

plotly_json() to view underlying JSON

zoo_dat <- tibble::tibble(
animal = rep(c('giraffes', 'orangutans', 'monkeys'), 2),
count = c(20, 14, 23, 12, 18, 29),
zoo = rep(c('SF Zoo', 'LA Zoo'), each = 3)
)
zoo_dat
#> # A tibble: 6 x 3
#> animal count zoo
#> <chr> <dbl> <chr>
#> 1 giraffes 20 SF Zoo
#> 2 orangutans 14 SF Zoo
#> 3 monkeys 23 SF Zoo
#> 4 giraffes 12 LA Zoo
#> 5 orangutans 18 LA Zoo
#> 6 monkeys 29 LA Zoo
plot_ly(zoo_dat) %>%
add_trace(
x = ~animal,
y = ~count,
name = ~zoo,
type = "bar"
) %>%
# Verify we get one trace per zoo
plotly_json(FALSE)
"data": [
{
"x": ["giraffes", "orangutans", "monkeys"],
"y": [12, 18, 29],
"name": "LA Zoo",
"type": "bar"
}, {
"x": ["giraffes", "orangutans", "monkeys"],
"y": [20, 14, 23],
"name": "SF Zoo",
"type": "bar"
}
]
10 / 62

plot_ly() syntax

More common trace types have a dedicated "layer" function (e.g., add_bars()).

zoo_dat <- tibble::tibble(
animal = rep(c('giraffes', 'orangutans', 'monkeys'), 2),
count = c(20, 14, 23, 12, 18, 29),
zoo = rep(c('SF Zoo', 'LA Zoo'), each = 3)
)
zoo_dat
#> # A tibble: 6 x 3
#> animal count zoo
#> <chr> <dbl> <chr>
#> 1 giraffes 20 SF Zoo
#> 2 orangutans 14 SF Zoo
#> 3 monkeys 23 SF Zoo
#> 4 giraffes 12 LA Zoo
#> 5 orangutans 18 LA Zoo
#> 6 monkeys 29 LA Zoo
plot_ly(zoo_dat) %>%
add_bars(
x = ~animal,
y = ~count,
name = ~zoo
)

11 / 62

plot_ly() syntax

Discrete axis ordering: use factor levels (character strings appear alphabetically)

zoo_dat <- tibble::tibble(
animal = rep(c('giraffes', 'orangutans', 'monkeys'), 2),
count = c(20, 14, 23, 12, 18, 29),
zoo = rep(c('SF Zoo', 'LA Zoo'), each = 3)
)
zoo_dat
#> # A tibble: 6 x 3
#> animal count zoo
#> <chr> <dbl> <chr>
#> 1 giraffes 20 SF Zoo
#> 2 orangutans 14 SF Zoo
#> 3 monkeys 23 SF Zoo
#> 4 giraffes 12 LA Zoo
#> 5 orangutans 18 LA Zoo
#> 6 monkeys 29 LA Zoo
plot_ly(zoo_dat) %>%
add_bars(
x = ~factor(animal, c("monkeys", "giraffes", "orangutans")),
y = ~count,
name = ~zoo
)

12 / 62

plot_ly() color mapping

Mapping data to color will impose it's own color scheme

zoo_dat <- tibble::tibble(
animal = rep(c('giraffes', 'orangutans', 'monkeys'), 2),
count = c(20, 14, 23, 12, 18, 29),
zoo = rep(c('SF Zoo', 'LA Zoo'), each = 3)
)
zoo_dat
#> # A tibble: 6 x 3
#> animal count zoo
#> <chr> <dbl> <chr>
#> 1 giraffes 20 SF Zoo
#> 2 orangutans 14 SF Zoo
#> 3 monkeys 23 SF Zoo
#> 4 giraffes 12 LA Zoo
#> 5 orangutans 18 LA Zoo
#> 6 monkeys 29 LA Zoo
plot_ly(zoo_dat) %>%
add_bars(
x = ~animal,
y = ~count,
color = ~zoo
)

13 / 62

plot_ly() color mapping

Specify the range via colors (here a colorbrewer palette name, see RColorBrewer::brewer.pal.info for options)

zoo_dat <- tibble::tibble(
animal = rep(c('giraffes', 'orangutans', 'monkeys'), 2),
count = c(20, 14, 23, 12, 18, 29),
zoo = rep(c('SF Zoo', 'LA Zoo'), each = 3)
)
zoo_dat
#> # A tibble: 6 x 3
#> animal count zoo
#> <chr> <dbl> <chr>
#> 1 giraffes 20 SF Zoo
#> 2 orangutans 14 SF Zoo
#> 3 monkeys 23 SF Zoo
#> 4 giraffes 12 LA Zoo
#> 5 orangutans 18 LA Zoo
#> 6 monkeys 29 LA Zoo
plot_ly(zoo_dat) %>%
add_bars(
x = ~animal,
y = ~count,
color = ~zoo,
colors = "Accent"
)

14 / 62

plot_ly() color mapping

Provide your own palette if you want. See ?plot_ly for more details.

zoo_dat <- tibble::tibble(
animal = rep(c('giraffes', 'orangutans', 'monkeys'), 2),
count = c(20, 14, 23, 12, 18, 29),
zoo = rep(c('SF Zoo', 'LA Zoo'), each = 3)
)
zoo_dat
#> # A tibble: 6 x 3
#> animal count zoo
#> <chr> <dbl> <chr>
#> 1 giraffes 20 SF Zoo
#> 2 orangutans 14 SF Zoo
#> 3 monkeys 23 SF Zoo
#> 4 giraffes 12 LA Zoo
#> 5 orangutans 18 LA Zoo
#> 6 monkeys 29 LA Zoo
plot_ly(zoo_dat) %>%
add_bars(
x = ~animal,
y = ~count,
color = ~zoo,
colors = c(
"SF Zoo" = "black",
"LA Zoo" = "red"
)
)

15 / 62

plot_ly() syntax

Use color/colors for fill-color and stroke/strokes for outline-color

zoo_dat <- tibble::tibble(
animal = rep(c('giraffes', 'orangutans', 'monkeys'), 2),
count = c(20, 14, 23, 12, 18, 29),
zoo = rep(c('SF Zoo', 'LA Zoo'), each = 3)
)
zoo_dat
#> # A tibble: 6 x 3
#> animal count zoo
#> <chr> <dbl> <chr>
#> 1 giraffes 20 SF Zoo
#> 2 orangutans 14 SF Zoo
#> 3 monkeys 23 SF Zoo
#> 4 giraffes 12 LA Zoo
#> 5 orangutans 18 LA Zoo
#> 6 monkeys 29 LA Zoo
plot_ly(zoo_dat) %>%
add_bars(
x = ~animal,
y = ~count,
split = ~zoo,
stroke = ~zoo,
strokes = c(
"SF Zoo" = "black",
"LA Zoo" = "red"
)
)

16 / 62

Your turn

zoo_dat <- tibble::tibble(
animal = rep(c('giraffes', 'orangutans', 'monkeys'), 2),
count = c(20, 14, 23, 12, 18, 29),
zoo = rep(c('SF Zoo', 'LA Zoo'), each = 3)
)
zoo_dat
#> # A tibble: 6 x 3
#> animal count zoo
#> <chr> <dbl> <chr>
#> 1 giraffes 20 SF Zoo
#> 2 orangutans 14 SF Zoo
#> 3 monkeys 23 SF Zoo
#> 4 giraffes 12 LA Zoo
#> 5 orangutans 18 LA Zoo
#> 6 monkeys 29 LA Zoo
plot_ly(zoo_dat) %>%
add_bars(
x = ~animal,
y = ~count,
color = ~zoo,
stroke = "black",
span = I(5)
)

  1. What does the span argument control?
  2. Can you make the span be a function of count?
  3. Why isn't the stroke on this plot black?
05:00
17 / 62

Getting help

Documentation

Q&A

Debugging, in general

  • Use plotly_json() to inspect the underlying JSON

    • Especially useful for debugging things that map to plotly.js (e.g., color, stroke, ggplotly()).
  • plotly_json() should align with the figure reference https://plot.ly/javascript/reference

  • The online figure reference doesn't always reflect the version of plotly.js used in the R package, but schema() does!

18 / 62

plotly_json(): focus on data and layout

# If no plot is provided, it shows JSON of the previously printed plot
plotly_json()
19 / 62

plotly_json() should match up with plotly.js' schema()

  • ?plot_ly documents R-specific arguments (e.g., color, stroke, etc).
  • schema() documents the actual plotly.js attributes (e.g., marker.color, marker.line.color, etc).
20 / 62

Useful for finding plotly.js features

plot_ly(zoo_dat) %>%
add_bars(
x = ~animal,
y = ~count,
text = ~count,
textposition = "auto",
split = ~zoo,
stroke = I("black")
)

21 / 62

Also useful for learning about layout() & config()

plot_ly() %>%
layout(
xaxis = list(range = c(-5, 5)),
yaxis = list(range = c(-5, 5)),
annotations = list(
text = "Drag the rect",
x = 0.5, xref = "paper",
y = 0.5, yref = "paper"
),
# shapes types: rect, circle, line
shapes = list(
type = "rect",
x0 = 0, x1 = 1,
y0 = 0, y1 = -1,
fillcolor = "blue"
)
) %>%
# Two lesser known gems: editable & displayModeBar
config(
editable = TRUE,
displayModeBar = FALSE
)
22 / 62

A practical example of editable charts

Later on, we'll see some cool ways to leverage edit events in Shiny.

23 / 62

Various R/plotly stuff that we don't have time to cover

Too much to list, but here's a start!


The remainder of the workshop fits under linking views server-side with Shiny

24 / 62

Case study: drug reaction outcomes

25 / 62

drug_outcomes() acquires reaction outcomes for a given drug name

library(openfda) # remotes::install_github("ropenhealth/openfda")
library(dplyr)
drug_outcomes <- function(name) {
fda_query("/drug/event.json") %>%
fda_filter("patient.drug.openfda.generic_name", name) %>%
fda_count("patient.reaction.reactionoutcome") %>%
fda_exec() %>%
mutate(reaction = recode(term,
`1` = "Recovered/resolved",
`2` = "Recovering/resolving",
`3` = "Not recovered/not resolved",
`4` = "Recovered/resolved with sequelae",
`5` = "Fatal", `6` = "Unknown"))
}
drug_outcomes("fentanyl")
#> term count reaction
#> 1 5 31515 Fatal
#> 2 6 30624 Unknown
#> 3 1 14641 Recovered/resolved
#> 4 3 13691 Not recovered/not resolved
#> 5 2 4248 Recovering/resolving
#> 6 4 406 Recovered/resolved with sequelae
26 / 62

Visualizing outcomes for a few drugs

drugs <- c(
"fentanyl",
"oxycodone",
"morphine"
)
lapply(drugs, drug_outcomes) %>%
setNames(drugs) %>%
bind_rows(.id = "drug") %>%
plot_ly(
y = ~reaction,
x = ~count,
color = ~drug
) %>%
add_bars()

27 / 62

Same data, different look

28 / 62

Heatmap of 1000 drug outcomes (made with heatmaply and plotly)

Not only is the visual technique more scalable, but with heatmaply, it's easy to reorder columns so that "similar" drugs next to each other.

29 / 62

Bi-plot of 1000 drug outcomes (made with shiny and plotly)

30 / 62

Overview first, then zoom and filter, then details on demand

Ben Shneiderman

Sound advice for any high-dimensional datavis problem

31 / 62

The end goal

  • What follows is a series of exercises that build up to this linked scatterplot + bar chart functionality
32 / 62

Hello Shiny

Here's an Shiny app with a plotly graph that updates in response to a dropdown:

file.edit("shiny/00/app.R")
shiny::runApp("shiny/00")
33 / 62

Hello user input

Many htmlwidget packages allow you to listen to user interaction with the widget. Just to name a few, plotly, leaflet, and DT all have this functionality. Here's a simple DT example:

file.edit("shiny/01/app.R")
shiny::runApp("shiny/01")
34 / 62

Your Turn: Responding to click events

The following Shiny app shows how to access click events tied to the scatterplot.

file.edit("shiny/02/app.R")
shiny::runApp("shiny/02")

Follow the "Your Turn" directions to extend the functionality of this app (to see the "solution", run shiny::runApp("shiny/02-solution"))

05:00
35 / 62

Hello reactive values

The following Shiny app prints the time difference between button clicks to the R console.

file.edit("shiny/03/app.R")
shiny::runApp("shiny/03")

Note that:

  • In order to compute the difference, Shiny needs to 'remember' the time the last click occurred (reactiveVal() provides mechanism for doing so).
  • Reactive values are very similar to input values -- when their value changes, it 'invalidates' any code that depends on it.
36 / 62

Your Turn: Managing event data

The following Shiny app shows how to accumulate (i.e., track the history) of click events

file.edit("shiny/04/app.R")
shiny::runApp("shiny/04")

Follow the "Your Turn" directions to extend the functionality of this app. There are many ways to go about this, so there are two "solutions".

05:00
37 / 62

Your Turn: Routing event data

The following Shiny app shows how to color points on click.

file.edit("shiny/05/app.R")
shiny::runApp("shiny/05")

Follow the "Your Turn" directions to extend the functionality of this app (to see the "solution", run shiny::runApp("shiny/05-solution"))

05:00
38 / 62

Your Turn: Scoping event listeners

Click on the bar chart of the previous app.

  1. Why does this generate an error?
  2. Can you prevent the error from happening (Hint: see arguments section of ?event_data)?
03:00
39 / 62
40 / 62

However...

41 / 62

Improving performance

  • Everytime we click a point, the scatterplot is regenerated on the server. This means:

    1. The R code in renderPlotly() is executed.
    2. The plot's JSON is sent over-the-wire from server to client.
    3. plotly.js redraws the scatterplot from scratch
  • But, we only need to change the size/color of the clicked marker!

  • For 1,000 points, this 'naive' approach is fine, but updates will become unnecessarily responsive with larger data.

  • To acheive faster updates, we can trigger partial modifications to a plotly graph.

42 / 62

I wont' lie, plotlyProxy() is difficult to use!

  • It interfaces directly with plotly.js, so you need to be careful about:

    • 0-based indexing
    • R to JSON conversion
    • plotly.js' sometimes quirky interface
    • Can't use 'R-specific' things (e.g., no color/stroke argument)
  • When things go wrong, it can be difficult to debug...hopefully we'll make it easier in the future!

  • When things go right, it makes your app logic a bit more difficult to reason about (because, side effects!)

43 / 62

Hello Plotly.restyle

The following Shiny apps use plotly.js' restyle function to flip marker.color to black/red

file.edit("shiny/06/01-all-colors.R")
file.edit("shiny/06/02-single-color.R")
44 / 62

Your Turn: Restyling markers

The following Shiny app uses plotlyProxy() to avoid a full redraw of the scatterplot when a point is clicked.

file.edit("shiny/06/app.R")
shiny::runApp("shiny/06")

Your Turn: find the code that updates the clicked point, how does it work?

03:00
45 / 62

plotly.js methods I use most often

  1. restyle: modify trace(s).
  2. relayout: modify the layout.
  3. addTraces/deleteTraces: add/remove traces
  4. react: supply a new figure.
  5. extendTraces: add data to a trace.
    • Useful for streaming data
plotly_example("shiny", "stream")

We're not going to learn these, just know they are available to you!

46 / 62

Basic drill-down with shiny+plotly

  • I often get asked about drill-down in Shiny or plotly.
    • There's no 1st class support, but that's OK!
  • If you can "manage state" with reactiveVal(), you can implement your own drill-down!
47 / 62

The sales dataset

sales <- readr::read_csv("data/sales.csv")
select(sales, category, sub_category, sales)
#> # A tibble: 9,994 x 3
#> category sub_category sales
#> <chr> <chr> <dbl>
#> 1 Furniture Bookcases 262.
#> 2 Furniture Chairs 732.
#> 3 Office Supplies Labels 14.6
#> 4 Furniture Tables 958.
#> 5 Office Supplies Storage 22.4
#> 6 Furniture Furnishings 48.9
#> 7 Office Supplies Art 7.28
#> 8 Technology Phones 907.
#> 9 Office Supplies Binders 18.5
#> 10 Office Supplies Appliances 115.
#> # … with 9,984 more rows
sales %>%
count(category, wt = sales)
#> # A tibble: 3 x 2
#> category n
#> <chr> <dbl>
#> 1 Furniture 742000.
#> 2 Office Supplies 719047.
#> 3 Technology 836154.
48 / 62

Sales by category

sales %>%
count(category, wt = sales)
#> # A tibble: 3 x 2
#> category n
#> <chr> <dbl>
#> 1 Furniture 742000.
#> 2 Office Supplies 719047.
#> 3 Technology 836154.
sales %>%
count(category, wt = sales) %>%
plot_ly() %>%
add_pie(labels = ~category, values = ~n)

49 / 62

Sales within furniture

sales <- readr::read_csv("data/sales.csv")
sales %>%
filter(category == "Furniture") %>%
count(sub_category, wt = sales)
#> # A tibble: 4 x 2
#> sub_category n
#> <chr> <dbl>
#> 1 Bookcases 114880.
#> 2 Chairs 328449.
#> 3 Furnishings 91705.
#> 4 Tables 206966.
sales %>%
filter(category == "Furniture") %>%
count(sub_category, wt = sales) %>%
plot_ly() %>%
add_pie(labels = ~sub_category, values = ~n)

50 / 62

Drill-down demo

The following Shiny app implements a drill-down for sales category/sub-category

file.edit("shiny/07/app.R")
shiny::runApp("shiny/07")

Let's add a title to the sub-category pie chart showing what category is currently selected.

51 / 62

Your turn

  • Watch the other 3 videos of drill-down visualizations of this same sales dataset listed here

https://plotly-r.com/linking-views-with-shiny.html#drill-down

  • How many reactive values are needed in each example?

(Hint: remember that one reactiveVal() was used to remember which category was clicked in the pie drill-down)

05:00
52 / 62

event_data() supports many event types!

53 / 62

Common 2D events

plotly_example("shiny", "event_data")
54 / 62

Advanced application: cross-filtering

  • Makes clever use of plotly_brushing, event scoping, and Plotly.restyle() to efficiently alter bar heights
plotly_example("shiny", "crossfilter")
55 / 62

3D events

Brushing events aren't (yet) supported, but most everything else is!

plotly_example("shiny", "event_data_3D")
56 / 62

Relayout events

ui <- fluidPage(
plotlyOutput("p"),
verbatimTextOutput("info")
)
server <- function(input, output) {
output$p <- renderPlotly({
plot_ly(x = 1, y = 1)
})
output$info <- renderPrint({
event_data("plotly_relayout")
})
}
shinyApp(ui, server)

57 / 62

Listening to layout edits

ui <- fluidPage(
plotlyOutput("p"),
verbatimTextOutput("info")
)
server <- function(input, output) {
output$p <- renderPlotly({
plot_ly(x = 1, y = 1) %>%
layout(
shapes = list(
type = "line",
x0 = 0.5, y0 = 0,
x1 = 0.5, y1 = 1,
xref = "paper",
yref = "paper"
)
) %>%
config(edits = list(shapePosition = TRUE))
})
output$info <- renderPrint({
event_data("plotly_relayout")
})
}

58 / 62

Responding to line edits

plotly_example("shiny", "drag_lines")
59 / 62

Responding to marker edits

plotly_example("shiny", "drag_markers")
60 / 62

Your Turn: (Final) your turn

Choose one of the following:

  1. Ask me a question (through sli.do)

  2. Apply something you learned to a dataset of your choice.

  3. Run a plotly_example() from the slides and dissect how it works.

61 / 62

Your turn

(1) Open the slides in Firefox: bit.ly/R_Pharma


(2) Go to this address https://rstudio.cloud/project/446255. This hosted RStudio instance contains materials for today's workshop.

  • Login or sign up to RStudio Cloud (it's free)
  • Click "Save a Permanent Copy" to copy the project to your account.


(3) Ask me any question at any time by going to slido.com and enter event code #8464 (or use this link). I'll try to check these questions periodically (upvote questions if you'd like them answered!)

05:00
2 / 62
Paused

Help

Keyboard shortcuts

, , Pg Up, k Go to previous slide
, , Pg Dn, Space, j Go to next slide
Home Go to first slide
End Go to last slide
Number + Return Go to specific slide
b / m / f Toggle blackout / mirrored / fullscreen mode
c Clone slideshow
p Toggle presenter mode
t Restart the presentation timer
?, h Toggle this help
Esc Back to slideshow