← Volver

Tracker en tiempo real de la ISS con R Shiny

ISS Tracker (captura/imagen ilustrativa)

La ISS no está “ahí arriba” quieta: va a unos 28.000 km/h y completa una vuelta a la Tierra aproximadamente cada 90 minutos. Y lo mejor es que podemos verla moverse en directo con datos públicos.

En este tutorial vamos a montar paso a paso un ISS Tracker con R Shiny: una mini-app que consulta la posición de la estación cada X segundos y la convierte en un dashboard interactivo.

1) Datos

Lo primero que necesitamos es una fuente de datos en tiempo real. Para eso usamos la API pública Where The ISS At?, que nos devuelve en JSON la información actual de la estación. El endpoint que consultamos es: https://api.wheretheiss.at/v1/satellites/25544.

Ese 25544 es el identificador del satélite/objeto (la ISS) dentro de la API. Con una petición GET obtenemos valores como: latitude, longitude, altitude, velocity y un timestamp (en segundos Unix, UTC).

Nosotros no usaremos “todo lo que devuelve la API”, solo lo que necesitamos para visualizar y analizar. Por eso nos quedamos con 6 campos y los dejamos con nombres consistentes:

2) Preparar el entorno

Antes de construir la app, instalamos y cargamos los paquetes que nos van a cubrir todo el flujo: pedir datos (HTTP), interpretarlos (JSON), guardar/actualizar (reactividad), y visualizarlos (mapa/gráfico/tabla).

Si es la primera vez que ejecutas el proyecto en tu máquina, instala los paquetes (solo una vez). Luego, en cada sesión, basta con cargarlos con library().

install.packages(c("shiny","httr","jsonlite","plotly","DT"))

library(shiny)
library(httr)
library(jsonlite)
library(plotly)
library(DT)

3) La función que trae los datos del ISS

Este es el primer “truco” de una app Shiny bien montada: encapsular la llamada a la API en una función. Así convertimos “una petición web” en algo que el resto del código puede usar como si fuese una fuente de datos normal.

Nuestra función tiene un objetivo muy concreto: devolver siempre un data.frame de una sola fila, con las columnas que vamos a usar en el dashboard. Esto es súper útil porque luego, en el server, podemos ir acumulando puntos con un simple rbind() sin preocuparnos de formatos raros.

Además, cuidamos dos cosas importantes para que la app sea estable:

Vamos línea por línea a nivel conceptual:

actualizar_posicion_iss <- function() {
  # 1) Endpoint del ISS (25544 es el identificador del satélite en la API)
  url <- "https://api.wheretheiss.at/v1/satellites/25544"

  # 2) Petición HTTP con timeout para que la app no se quede colgada
  r <- GET(url, timeout(5))

  # 3) Si la respuesta no es 200 OK, lanzamos error (lo capturaremos con tryCatch fuera)
  stop_for_status(r)

  # 4) Parseamos JSON a objeto de R
  x <- fromJSON(content(r, as = "text", encoding = "UTF-8"))

  # 5) Devolvemos un data.frame de una fila, con tipos y nombres consistentes
  data.frame(
    timestamp = as.numeric(x$timestamp),
    time_utc  = as.POSIXct(as.numeric(x$timestamp), origin = "1970-01-01", tz = "UTC"),
    lat       = as.numeric(x$latitude),
    lon       = as.numeric(x$longitude),
    altitude_km  = as.numeric(x$altitude),
    velocity_kmh = as.numeric(x$velocity)
  )
}

Con esta función ya tenemos una “pieza” reutilizable: cada vez que la llamemos, obtenemos un punto listo para dibujar (mapa/gráfico) y para guardar (tabla/CSV). A partir de aquí, Shiny solo tiene que decidir cuándo llamarla (cada X segundos) y cómo pintar el resultado.

4) Diseñar la interfaz (UI)

Nuestra UI sigue un patrón muy cómodo: barra lateral con controles y, a la derecha, un panel con pestañas. De cara a usuario, el flujo queda clarísimo: eliges el refresco, das a Start/Stop, y exploras mapa / velocidad / tabla.

ui <- fluidPage(
  titlePanel("ISS Tracker"),

  sidebarLayout(
    sidebarPanel(
      sliderInput("refresh", "Refresco (segundos)", min = 1, max = 10, value = 3, step = 1),

      fluidRow(
        column(6, actionButton("start", "Start", class = "btn-success", style = "width:100%")),
        column(6, actionButton("stop",  "Stop",  class = "btn-danger",  style = "width:100%"))
      ),

      tags$hr(),

      actionButton("clear", "Limpiar trayectoria", style = "width:100%"),
      downloadButton("descargar_csv", "Descargar CSV", style = "width:100%; margin-top:8px;"),

      tags$hr(),

      h4("Log"),
      verbatimTextOutput("status", placeholder = TRUE),

      h4("Datos Actuales"),
      tags$ul(
        tags$li(strong("Velocidad: "), textOutput("velocidad_actual", inline = TRUE)),
        tags$li(strong("Altitud: "),  textOutput("altitud_actual",  inline = TRUE)),
        tags$li(strong("Puntos guardados: "), textOutput("num_puntos", inline = TRUE))
      )
    ),

    mainPanel(
      tabsetPanel(
        tabPanel("Mapa", plotlyOutput("map", height = "520px")),
        tabPanel("Velocidad", plotlyOutput("speed", height = "420px")),
        tabPanel("Tabla", DTOutput("table"))
      )
    )
  )
)

5) El corazón de Shiny: estado interno + actualización periódica

En Shiny todo gira en torno a la reactividad. El problema es que, si queremos que la app “recuerde cosas” (por ejemplo, si está corriendo el tracking y qué puntos llevamos acumulados), necesitamos un sitio donde guardar ese estado. Para eso usamos reactiveValues(): es como una “mochila” interna de la app que podemos ir actualizando, y cualquier output que dependa de esos valores se refrescará automáticamente.

En nuestro caso, guardamos tres piezas clave:

A partir de ahí, necesitamos algo que haga el “tick” automático para pedir la posición cada cierto tiempo. En Shiny, esto se hace con invalidateLater() dentro de un observe(). La idea es súper simple: si el tracking está activo, este bloque se volverá a ejecutar dentro de X segundos.

Y aquí entra una línea muy importante: req(rv$running). Esa llamada actúa como un “guardia de seguridad”: si running es FALSE, el bloque no continúa y, por tanto, no se programa el siguiente tick. Resultado: cuando paramos el tracking, dejamos de llamar a la API y la app se queda quieta (sin consumo innecesario).

rv <- reactiveValues(
  running = FALSE,
  last_msg = "Parado",
  track = data.frame(
    timestamp=numeric(), time_utc=as.POSIXct(character(), tz="UTC"),
    lat=numeric(), lon=numeric(),
    altitude_km=numeric(), velocity_kmh=numeric(),
    stringsAsFactors = FALSE
  )
)

observeEvent(input$start, {
  rv$running <- TRUE
  rv$last_msg <- "Tracking ON"

  pt <- tryCatch(actualizar_posicion_iss(), error = function(e) NULL)

  if (!is.null(pt)) {
    rv$track <- rbind(rv$track, pt)
    rv$last_msg <- paste0("OK (primer punto: ", format(pt$time_utc, "%H:%M:%S UTC"), ")")
  } else {
    rv$last_msg <- "No pude contactar con la API (revisa conexión / firewall)"
  }
})

observe({
  req(rv$running)
  invalidateLater(input$refresh * 1000, session)

  pt <- tryCatch(actualizar_posicion_iss(), error = function(e) NULL)
  if (is.null(pt)) {
    rv$last_msg <- "Error llamando a la API (reintentando...)"
    return()
  }

  rv$track <- rbind(rv$track, pt)
  rv$last_msg <- paste0("OK (último update: ", format(pt$time_utc, "%H:%M:%S UTC"), ")")
})
Truco importante: invalidateLater() no crea un “bucle infinito” real. Lo que hace es programar una nueva ejecución del contexto reactivo. Cada vez que el bloque corre, vuelve a dejar preparado el siguiente “tick”.

6) Visualización 1: el mapa con la trayectoria

En el mapa queremos mostrar una idea muy simple: una secuencia de puntos (lat, lon) a lo largo del tiempo. Si conectamos esos puntos en orden, obtenemos la trayectoria reciente de la ISS; y si además resaltamos el último punto, el usuario entiende al instante “por dónde ha pasado” y “dónde está ahora”.

Para esto usamos scattergeo de :contentReference[oaicite:0]{index=0}, que está pensado justo para coordenadas geográficas (latitud/longitud) y nos da zoom, arrastre, hover y proyección del mapa sin complicarnos con tiles o servidores.

La visualización se construye con dos capas (dos traces):

  1. Trayectoria (lines+markers): pinta todos los puntos acumulados y los conecta con una línea para ver el recorrido.
  2. Última posición (markers): pinta solo el último punto con un marcador más grande para que destaque.

Además, definimos un hover personalizado usando text (con saltos de línea en HTML <br>) y hoverinfo = "text", para que al pasar el ratón veamos exactamente lo que nos interesa: hora UTC, lat/lon, altitud y velocidad.

Por último, en layout(geo = ...) elegimos una proyección agradable (natural earth), activamos tierra y países (showland, showcountries) y ajustamos márgenes/leyenda para que se vea limpio en el panel de Shiny.

output$map <- renderPlotly({
  # Si todavía no tenemos puntos, devolvemos un plot vacío con un título (evita errores y guía al usuario)
  if (nrow(rv$track) < 1) {
    return(plotly_empty() %>% layout(title = "Pulsa Start para cargar la posición del ISS"))
  }

  # Datos acumulados y último punto (posición actual)
  df   <- rv$track
  last <- df[nrow(df), ]

  plot_ly() %>%
    # 1) Trayectoria completa: línea + puntos
    add_trace(
      data = df,
      type = "scattergeo",
      mode = "lines+markers",
      lon = ~lon, lat = ~lat,
      text = ~paste0(
        "UTC: ", time_utc,
        "<br>Lat: ", round(lat, 3),
        "<br>Lon: ", round(lon, 3),
        "<br>Alt: ", round(altitude_km, 1), " km",
        "<br>Vel: ", round(velocity_kmh, 0), " km/h"
      ),
      hoverinfo = "text",
      name = "Trayectoria"
    ) %>%
    # 2) Último punto: marcador más grande para destacar “dónde está ahora”
    add_trace(
      data = last,
      type = "scattergeo",
      mode = "markers",
      lon = ~lon, lat = ~lat,
      marker = list(size = 10),
      name = "Última posición"
    ) %>%
    # Ajustes de mapa (proyección, capas visibles, márgenes)
    layout(
      geo = list(
        projection = list(type = "natural earth"),
        showland = TRUE,
        showcountries = TRUE
      ),
      margin = list(l=10, r=10, t=30, b=10),
      legend = list(orientation = "h")
    )
})

7) Visualización 2: velocidad a lo largo del tiempo

El mapa nos dice dónde está la ISS, pero la velocidad nos cuenta cómo se está moviendo. En teoría la ISS mantiene una velocidad bastante estable (en torno a ~27.000–28.000 km/h), así que este gráfico es muy útil por dos motivos:

Para dibujarlo usamos un scatter de :contentReference[oaicite:0]{index=0} en modo lines+markers: línea para seguir la tendencia y puntos para ver cada actualización individual. En el eje X ponemos time_utc (el timestamp convertido a fecha/hora), que es lo natural cuando trabajamos con series temporales: el gráfico queda ordenado y es fácil relacionarlo con el refresco (cada X segundos).

También definimos un hover compacto con hovertemplate, así al pasar el ratón no necesitas leer ejes: te muestra la hora exacta y la velocidad redondeada.

output$speed <- renderPlotly({
  df <- rv$track

  # Si todavía no hay datos, devolvemos un plot vacío con un mensaje claro
  if (nrow(df) < 1) {
    return(plotly_empty(type = "scatter", mode = "lines") %>% 
             layout(title = "Pulsa Start para ver la velocidad"))
  }

  plot_ly(
    data = df,
    x = ~time_utc,
    y = ~velocity_kmh,
    type = "scatter",
    mode = "lines+markers",
    # Hover limpio: hora y velocidad (sin la caja extra de serie)
    hovertemplate = "UTC: %{x}<br>Velocidad: %{y:.0f} km/h<extra></extra>"
  ) %>%
    layout(
      title = "Velocidad (km/h)",
      xaxis = list(title = "Tiempo (UTC)"),
      yaxis = list(title = "km/h"),
      # Márgenes un poco más amplios a la izquierda/abajo para que no se corten etiquetas
      margin = list(l=60, r=10, t=50, b=60)
    )
})

8) Tabla (DT) + descarga a CSV

La tabla es la forma más “honesta” de ver los datos: cada fila es un update. Y como la anchura puede crecer (sobre todo si añadimos más variables), activamos scrollX = TRUE.

Para exportar, downloadHandler() escribe track en un CSV con timestamp en el nombre. Esto nos viene genial para llevarnos el archivo a un notebook y hacer análisis aparte.

output$table <- renderDT({
  datatable(
    rv$track,
    options = list(pageLength = 10, scrollX = TRUE),
    rownames = FALSE
  )
})

output$descargar_csv <- downloadHandler(
  filename = function() paste0("trayectoria_iss_", format(Sys.time(), "%Y%m%d_%H%M%S"), ".csv"),
  content  = function(file) write.csv(rv$track, file, row.names = FALSE)
)

9) Dato curioso: ¿por qué la trayectoria parece una “S tumbada”?

Espacio (imagen ilustrativa)

Si dejamos la app corriendo un rato, la trayectoria dibujada en un mapa 2D suele parecer una onda o una “S”. No es que la ISS haga eses: es un efecto de proyección y referencia.

10) Cómo ejecutarlo (local)

Ejecución local

Si tienes el archivo iss.R en una carpeta, lo más directo es abrir R/RStudio en esa carpeta y ejecutar:

shiny::runApp()
Abrir la demo en una pestaña
📓 Código incluido

Descarga el código

Si quieres replicarlo exactamente igual y jugar con él, aquí tienes el código completo listo para ejecutar.

Fuentes y enlaces

  1. API “Where The ISS At?” (documentación y endpoint del satélite 25544). wheretheiss.at
  2. Documentación de invalidateLater() (Shiny). shiny.posit.co
  3. Plotly en R – referencia de scattergeo. plotly.com
  4. DT (R) – documentación oficial (DataTables en Shiny). rstudio.github.io/DT
  5. DataTables – opción scrollX (scroll horizontal). datatables.net