Caso completo: dashboard con módulos, plotly y deploy

r
shiny
Un dashboard real end-to-end con NYC flights. Arquitectura modular, UI con bslib + value_box, gráficos plotly con selección que filtra, tabla reactable con descarga CSV, y deployment paso a paso.

El cierre de la ruta

Has completado 11 tutoriales sobre Shiny. Esta entrega no introduce conceptos nuevos: junta todo en un dashboard real, desde la primera línea hasta la app desplegada en shinyapps.io.

Reusamos los datos de nycflights13, los mismos vuelos de NYC 2013 que vimos en la Ruta 1 y la Ruta 2. El cambio: ahora son interactivos.

El proyecto

“Construir un dashboard que un analista pueda usar para explorar la puntualidad de aerolíneas en NYC, con filtros, KPIs, gráficos interactivos y tabla descargable.”

Estructura del proyecto

dashboard-vuelos/
├── app.R
├── R/
│   ├── mod_filtros.R
│   ├── mod_kpis.R
│   ├── mod_grafico.R
│   └── mod_tabla.R
├── www/
│   └── styles.css         # opcional, custom CSS
└── renv.lock              # dependencias fijadas

Shiny carga automáticamente cualquier .R en R/, es donde van los módulos. app.R es la composición final.

Paso 1: Cargar paquetes y datos

# R/setup.R (también en app.R, lo que prefieras)
library(shiny)
library(bslib)
library(dplyr)
library(ggplot2)
library(plotly)
library(reactable)
library(scales)
library(nycflights13)

# Pre-procesar una vez al iniciar el server (fuera de session)
vuelos <- flights |>
  filter(!is.na(dep_delay)) |>
  mutate(fecha = as.Date(paste(year, month, day, sep = "-"))) |>
  left_join(airlines, by = "carrier") |>
  rename(aerolinea = name) |>
  select(fecha, month, aerolinea, origin, dep_delay, arr_delay, distance)

Carga fuera del server → corre una vez al arrancar, no por usuario.

Paso 2: Módulo de filtros (R/mod_filtros.R)

filtros_ui <- function(id) {
  ns <- NS(id)
  tagList(
    selectInput(ns("aerolinea"), "Aerolínea:",
                choices = c("Todas" = "todas"), multiple = FALSE),
    selectInput(ns("origen"), "Aeropuerto de origen:",
                choices = c("Todos" = "todos", "JFK", "LGA", "EWR")),
    sliderInput(ns("mes"), "Rango de meses:",
                min = 1, max = 12, value = c(1, 12), step = 1)
  )
}

filtros_server <- function(id, datos) {
  moduleServer(id, function(input, output, session) {
    # Poblar el selector de aerolíneas dinámicamente
    observe({
      aerolineas <- c("Todas" = "todas", sort(unique(datos$aerolinea)))
      updateSelectInput(session, "aerolinea", choices = aerolineas)
    })

    # Reactive con los datos filtrados
    reactive({
      d <- datos
      if (input$aerolinea != "todas") d <- d |> filter(aerolinea == input$aerolinea)
      if (input$origen != "todos")    d <- d |> filter(origin == input$origen)
      d |> filter(between(month, input$mes[1], input$mes[2]))
    })
  })
}

Tres filtros + un reactive que devuelve el dataset filtrado. El módulo encapsula toda la lógica del filtrado.

Paso 3: Módulo de KPIs (R/mod_kpis.R)

kpis_ui <- function(id) {
  ns <- NS(id)
  layout_columns(
    value_box(
      title = "Vuelos",
      value = textOutput(ns("n_vuelos")),
      theme = "primary"
    ),
    value_box(
      title = "Retraso mediano",
      value = textOutput(ns("retraso_mediano")),
      theme = "warning"
    ),
    value_box(
      title = "% a tiempo",
      value = textOutput(ns("pct_tiempo")),
      theme = "success"
    )
  )
}

kpis_server <- function(id, datos_filt) {
  moduleServer(id, function(input, output, session) {
    output$n_vuelos <- renderText({
      comma(nrow(datos_filt()))
    })

    output$retraso_mediano <- renderText({
      paste0(round(median(datos_filt()$dep_delay), 1), " min")
    })

    output$pct_tiempo <- renderText({
      pct <- mean(datos_filt()$dep_delay <= 0) * 100
      paste0(round(pct, 1), "%")
    })
  })
}

Tres value_box clásicos. El módulo recibe los datos filtrados como reactive() y calcula los KPIs.

Paso 4: Módulo de gráfico interactivo (R/mod_grafico.R)

grafico_ui <- function(id) {
  ns <- NS(id)
  card(
    card_header("Retraso medio por aerolínea y mes"),
    plotlyOutput(ns("grafico"))
  )
}

grafico_server <- function(id, datos_filt) {
  moduleServer(id, function(input, output, session) {
    output$grafico <- renderPlotly({
      validate(
        need(nrow(datos_filt()) > 0, "No hay datos con los filtros actuales.")
      )

      resumen <- datos_filt() |>
        group_by(aerolinea, month) |>
        summarise(
          retraso_medio = mean(dep_delay, na.rm = TRUE),
          n             = n(),
          .groups       = "drop"
        ) |>
        filter(n >= 30)  # ignorar combinaciones con pocos vuelos

      p <- ggplot(resumen, aes(month, retraso_medio, color = aerolinea)) +
        geom_line(linewidth = 0.6) +
        geom_point(size = 1.5) +
        scale_x_continuous(breaks = 1:12, labels = month.abb) +
        scale_y_continuous(labels = label_number(suffix = " min")) +
        scale_color_viridis_d(option = "viridis", end = 0.9) +
        labs(x = NULL, y = NULL, color = NULL) +
        theme_minimal()

      ggplotly(p) |>
        config(displayModeBar = FALSE)
    })
  })
}

validate() muestra mensaje si los filtros dejan cero filas. ggplotly() añade interactividad gratis. config(displayModeBar = FALSE) oculta la barra de plotly para una UI más limpia.

Paso 5: Módulo de tabla con descarga (R/mod_tabla.R)

tabla_ui <- function(id) {
  ns <- NS(id)
  card(
    card_header(
      "Vuelos detallados",
      class = "d-flex justify-content-between",
      downloadButton(ns("descargar"), "Descargar CSV", class = "btn-sm")
    ),
    reactableOutput(ns("tabla"))
  )
}

tabla_server <- function(id, datos_filt) {
  moduleServer(id, function(input, output, session) {
    output$tabla <- renderReactable({
      reactable(
        datos_filt(),
        defaultPageSize = 15,
        searchable      = TRUE,
        filterable      = TRUE,
        striped         = TRUE,
        highlight       = TRUE,
        columns = list(
          fecha     = colDef(name = "Fecha"),
          aerolinea = colDef(name = "Aerolínea"),
          origin    = colDef(name = "Origen", align = "center"),
          dep_delay = colDef(name = "Retraso salida", format = colFormat(suffix = " min")),
          arr_delay = colDef(name = "Retraso llegada", format = colFormat(suffix = " min")),
          distance  = colDef(name = "Distancia", format = colFormat(suffix = " mi")),
          month     = colDef(show = FALSE)
        )
      )
    })

    output$descargar <- downloadHandler(
      filename = function() {
        paste0("vuelos-", format(Sys.Date(), "%Y-%m-%d"), ".csv")
      },
      content = function(file) {
        write.csv(datos_filt(), file, row.names = FALSE)
      }
    )
  })
}

reactable con filtros por columna + búsqueda global. downloadHandler para que el usuario descargue los datos filtrados a CSV, patrón estándar.

Paso 6: Composición en app.R

ui <- page_sidebar(
  title = "Puntualidad de vuelos — NYC 2013",
  theme = bs_theme(
    version = 5,
    bg = "#FAF9F6", fg = "#2A2A2A",
    primary = "#5F8575",
    base_font = font_google("Inter Tight"),
    heading_font = font_google("Newsreader")
  ),
  sidebar = sidebar(
    title = "Filtros",
    filtros_ui("filtros")
  ),
  kpis_ui("kpis"),
  grafico_ui("grafico"),
  tabla_ui("tabla")
)

server <- function(input, output, session) {
  datos_filtrados <- filtros_server("filtros", vuelos)

  kpis_server("kpis", datos_filtrados)
  grafico_server("grafico", datos_filtrados)
  tabla_server("tabla", datos_filtrados)
}

shinyApp(ui, server)

El server principal son cuatro líneas. Todo lo demás vive en módulos. Cuando crezca, añadir un módulo es una línea más. Refactorizar uno no toca los otros.

Esto es lo que hace módulos esencial: el app.R queda declarativo, describe la composición, no la lógica.

Paso 7: Deployment con renv

Antes de subir, fija las dependencias:

# Una vez en el proyecto
renv::init()      # crea renv.lock con tus paquetes actuales

# Cuando añadas paquetes nuevos
renv::snapshot()  # actualiza renv.lock

renv.lock registra las versiones exactas. Al desplegar, shinyapps.io (y cualquier otro target) las instalará para reproducir tu entorno.

Deployment a shinyapps.io:

rsconnect::deployApp(
  appDir = ".",
  appName = "puntualidad-nyc",
  appTitle = "Puntualidad de vuelos NYC 2013",
  forceUpdate = TRUE
)

URL resultante: https://tu_usuario.shinyapps.io/puntualidad-nyc/.

Resumen visual de la arquitectura

                    ┌────────────────┐
                    │ filtros_server │
                    │   (datos)      │
                    └───────┬────────┘
                            │ reactive(datos_filtrados)
                            │
            ┌───────────────┼───────────────┐
            │               │               │
            ▼               ▼               ▼
    ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
    │ kpis_server │  │ grafico_server │  │ tabla_server│
    └─────────────┘  └─────────────┘  └─────────────┘

Un módulo de filtros produce un reactive. Tres módulos consumidores lo leen. Cero acoplamiento entre los consumidores, cualquiera se puede quitar, cambiar o duplicar sin afectar a los demás.

Lo que has hecho

En este caso completo has aplicado los once tutoriales anteriores en orden:

  1. Decisión inicial de usar Shiny (Ruta 5 - tutorial 1).
  2. Estructura del proyecto con R/ y carga única de datos (tutorial 2).
  3. Inputs (filtros) y outputs (value_box, plotly, reactable) (tutorial 3).
  4. Reactividad bien estructurada, un solo reactive() central alimentando consumidores (tutorial 4).
  5. UI moderna con bslib: page_sidebar, card, value_box, bs_theme con tipografía editorial (tutorial 5).
  6. Tabla con reactable + filtros por columna + descarga CSV (tutorial 6).
  7. Gráfico interactivo con ggplotly (tutorial 7).
  8. validate() para mensajes cuando los filtros dejan cero filas (tutorial 8).
  9. Cuatro módulos independientes con namespaces correctos (tutorial 9).
  10. (No usamos bookmarking explícito, pero la app está lista para añadirlo en un paso.)
  11. Deployment a shinyapps.io con renv para reproducibilidad (tutorial 11).

Y un app.R declarativo de 25 líneas que cualquier desarrollador puede leer y entender en un minuto.

¿Quieres ir más a fondo?

El libro Shiny para producción: del prototipo a la app desplegada (en preparación) desarrolla esta misma materia con:

  • Un dashboard corporativo real (no datos académicos).
  • Arquitectura con golem, el framework para apps Shiny en producción.
  • Testing con shinytest2.
  • Autenticación enterprise (SSO via OAuth2).
  • Deployment con Docker + ShinyProxy + nginx en VPS.

Has terminado la Ruta 5

Felicidades. Si quieres seguir aprendiendo R, las rutas que naturalmente continúan son:

  • Python para análisis de datos: apertura al ecosistema Python.
  • Reproducibilidad con Quarto: para que tu trabajo sea ejecutable en otra máquina mañana.

Ambas están en el listado de Rutas.