Gráficos interactivos con plotly

r
shiny
ggplotly() para convertir cualquier ggplot a interactivo, plot_ly() nativo cuando hace falta, event_data() para capturar clicks, hovers y selecciones, y crosstalk para linkar widgets sin server.

plotly: ggplot2 + interactividad

plotly añade tres cosas que ggplot2 solo no tiene:

  1. Zoom y pan sin escribir código adicional.
  2. Tooltips automáticos al hover.
  3. Eventos que se capturan en el server de Shiny (click, selección, etc.).
install.packages(c("plotly", "shiny"))
library(plotly)

Es el complemento natural de ggplot2 en Shiny. La mayoría de gráficos que enseñas en una app deberían ser plotly, la interactividad es gratuita y los usuarios la esperan.

ggplotly(): conversión directa

library(ggplot2)
library(plotly)

g <- ggplot(mtcars, aes(wt, mpg, color = factor(cyl))) +
  geom_point(size = 3, alpha = 0.7) +
  labs(color = "Cilindros")

ggplotly(g)

Una línea. El gráfico aparece con todo:

  • Zoom: arrastra para hacer zoom rectangular.
  • Pan: shift + arrastrar para moverte.
  • Doble click: resetea el zoom.
  • Hover: tooltip con los valores.
  • Leyenda interactiva: click en un nivel oculta/muestra ese grupo.

Funciona con casi cualquier geom de ggplot2. Algunos casos exóticos (geoms muy customizados, themes con tipografía rara) pueden renderizar con discrepancias menores, un 95 % de tus gráficos pasan sin problemas.

Personalizar el tooltip

Por defecto, el tooltip muestra todos los aesthetics mapeados. Para controlar qué se muestra:

g <- ggplot(mtcars, aes(wt, mpg,
                       color = factor(cyl),
                       text = paste0(
                         "Modelo: ", rownames(mtcars), "<br>",
                         "Peso: ", wt, "<br>",
                         "MPG: ", mpg
                       ))) +
  geom_point()

ggplotly(g, tooltip = "text")

El aesthetic text (no estándar de ggplot2. Lo añades específicamente para plotly) controla qué aparece en el tooltip. tooltip = "text" le dice a plotly que solo use ese campo.

plot_ly() nativo: cuándo

Para casos donde ggplotly no te da lo que necesitas, plot_ly() es la API nativa:

plot_ly(mtcars,
        x = ~wt,
        y = ~mpg,
        color = ~factor(cyl),
        type = "scatter",
        mode = "markers",
        marker = list(size = 10))

La sintaxis usa ~variable (formula) para mapear columnas, distinto a ggplot2. Útil cuando:

  • Necesitas plots 3D (type = "scatter3d").
  • Necesitas tipos de gráfico que ggplot2 no tiene (sunburst, treemap, sankey).
  • Necesitas control total sobre la interactividad y eventos.

Para el 80 % de los casos, ggplotly(ggplot(...)) es suficiente y aprovechas tu conocimiento de ggplot2.

Capturar eventos: plotlyOutput + event_data

Aquí está la magia que combina con Shiny. Cuando el usuario interactúa con el plot, plotly emite eventos que el server puede capturar:

library(shiny)
library(plotly)

ui <- fluidPage(
  plotlyOutput("grafico"),
  verbatimTextOutput("info")
)

server <- function(input, output, session) {
  output$grafico <- renderPlotly({
    plot_ly(mtcars, x = ~wt, y = ~mpg,
            color = ~factor(cyl),
            type = "scatter", mode = "markers",
            source = "scatter_main")
  })

  output$info <- renderPrint({
    click <- event_data("plotly_click", source = "scatter_main")
    if (is.null(click)) {
      cat("Haz click en un punto para ver los detalles")
    } else {
      cat("Punto seleccionado:\n")
      print(click)
    }
  })
}

shinyApp(ui, server)

Tres detalles importantes:

  • source = "id_unico" en plot_ly() te permite tener varios gráficos en la misma app y distinguir cuál disparó el evento.
  • event_data("plotly_click", source = "...") devuelve los datos del evento. Es reactivo, al cambiar, recalcula los outputs que lo usan.
  • Si no hay evento aún (al cargar la app), event_data() devuelve NULL. Maneja siempre el caso NULL con req() o un if.

Eventos disponibles

Evento Cuándo
"plotly_click" Click en un punto
"plotly_hover" Hover sobre un punto
"plotly_unhover" El cursor sale del punto
"plotly_selected" Selección rectangular o lasso completada
"plotly_selecting" Mientras el usuario está seleccionando
"plotly_brushed" Brush (selección persistente, en algunos modos)
"plotly_relayout" El usuario hace zoom o pan

Patrón para selección rectangular:

ui <- fluidPage(
  plotlyOutput("scatter"),
  DTOutput("tabla_seleccion")
)

server <- function(input, output, session) {
  output$scatter <- renderPlotly({
    plot_ly(mtcars, x = ~wt, y = ~mpg,
            type = "scatter", mode = "markers",
            source = "main") |>
      layout(dragmode = "select")   # activar modo selección por defecto
  })

  output$tabla_seleccion <- renderDT({
    sel <- event_data("plotly_selected", source = "main")
    req(sel)
    # sel$pointNumber contiene los índices de los puntos seleccionados
    mtcars[sel$pointNumber + 1, ]   # +1 porque plotly usa índice 0-based
  })
}

El usuario dibuja un rectángulo sobre el gráfico → la tabla de abajo muestra solo las filas seleccionadas. Es el patrón canónico de dashboards exploratorios.

Vinculación entre gráficos

Para que un click en un gráfico filtre otro, dos opciones:

1. Con Shiny + event_data (lo natural si ya tienes app Shiny):

server <- function(input, output, session) {
  filtrados <- reactive({
    sel <- event_data("plotly_selected", source = "grafico_1")
    if (is.null(sel)) return(mtcars)
    mtcars[sel$pointNumber + 1, ]
  })

  output$grafico_1 <- renderPlotly({ ... })
  output$grafico_2 <- renderPlotly({
    plot_ly(filtrados(), x = ~hp, y = ~qsec, type = "scatter", mode = "markers")
  })
}

Un reactive() que extrae la selección y los gráficos consumidores la usan.

2. Con crosstalk (sin server, útil para HTML estático):

library(crosstalk)

mt <- SharedData$new(mtcars)

bscols(
  plot_ly(mt, x = ~wt, y = ~mpg, type = "scatter", mode = "markers"),
  plot_ly(mt, x = ~hp, y = ~qsec, type = "scatter", mode = "markers")
)

SharedData$new() crea un objeto compartido. Cuando el usuario selecciona en un widget, el otro se filtra automáticamente, todo en JavaScript, sin necesidad de server R corriendo.

crosstalk es lo que necesitas para dashboards estáticos en Quarto o R Markdown. Si ya estás en Shiny, el patrón con event_data es más flexible y suele ser mejor.

Customización visual

Para ajustar el aspecto del plotly:

ggplotly(g) |>
  layout(
    title = list(text = "Mi gráfico", x = 0.5),
    xaxis = list(title = "Peso (1000 lb)"),
    yaxis = list(title = "Millas por galón"),
    plot_bgcolor = "#FAF9F6",
    paper_bgcolor = "#FAF9F6",
    font = list(family = "Inter, sans-serif")
  ) |>
  config(displayModeBar = FALSE)   # ocultar barra superior

layout() controla el aspecto del gráfico. config() controla la barra de herramientas que aparece arriba al hacer hover. displayModeBar = FALSE la oculta, útil cuando el público no técnico no la entiende y solo ensucia la UI.

Trampas habituales

  • Mezclar índices 0-based de plotly con 1-based de R. event_data()$pointNumber empieza en 0. Para indexar en R, suma 1: mtcars[sel$pointNumber + 1, ]. Olvidar el +1 te da la fila equivocada, sutil, difícil de depurar.
  • Olvidar source con múltiples gráficos. Sin source, todos los event_data reciben eventos de todos los plotly de la app. Empieza a poner source = "id" desde el primer gráfico.
  • Manejar event_data como si nunca fuera NULL. Al cargar la app, no hay evento todavía. event_data() devuelve NULL. Sin req() o un if, el código que lo usa falla con error críptico.
  • Convertir ggplots con ggplotly() cuando el theme tiene fuentes custom no estándar. El renderer de plotly no carga showtext automáticamente. Si has aplicado un theme con fuentes via showtext, el plotly resultante mostrará la fuente por defecto. Para evitarlo, especifica las fuentes con layout(font = ...) directamente en plotly.

En la siguiente entrega

Has aprendido a hacer gráficos y tablas interactivos. La siguiente entrega abre el Bloque 3 con validación de inputs y manejo de errores: cómo evitar que tu app muestre errores rojos al usuario y dar mensajes claros cuando algo no se puede calcular. Lo siguiente.