Tracker en tiempo real de la ISS con R Shiny
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:
- timestamp: el tiempo en formato Unix (número). Útil para ordenar y para guardar “tal cual”.
- time_utc: el mismo timestamp convertido a fecha/hora (
POSIXct) para poder dibujar ejes temporales. - lat y lon: la posición en coordenadas geográficas (para el mapa y la trayectoria).
- altitude_km: altitud (en km), por si queremos mostrarla o analizar cambios en la órbita.
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).
-
shiny: es el framework que convierte R en una aplicación web interactiva. Aquí vive lo importante: la UI, el server y el sistema reactivo que actualiza todo cuando cambian los datos. -
httr: nos permite hacer peticiones HTTP de forma cómoda (por ejemploGET()), añadir timeout y comprobar si la respuesta fue correcta (stop_for_status()). Es la “puerta de entrada” a la API del ISS. -
jsonlite: la API devuelve JSON, y este paquete se encarga de parsearlo y convertirlo a estructuras de R (listas / data frames) confromJSON(). -
plotly: lo usamos para las visualizaciones interactivas: el mapa (con hover, zoom y trayectoria) y el gráfico de velocidad. Se integra muy bien con Shiny usandorenderPlotly()yplotlyOutput(). -
DT: crea tablas interactivas basadas en DataTables (JavaScript) directamente desde R: ordenación, búsqueda, paginación y scroll horizontal. Ideal para ver el histórico de puntos sin perder usabilidad.
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:
-
Robustez de red: usamos
timeout(5)para que, si la conexión va mal o el servidor tarda, Shiny no se quede “congelado” esperando. -
Control de errores HTTP: con
stop_for_status(), si la API responde con un 4xx/5xx, lanzamos un error “limpio”. Después lo capturaremos contryCatch()para mostrar un mensaje en el log y seguir reintentando sin romper la app.
Vamos línea por línea a nivel conceptual:
- 1) GET a la URL: pedimos el estado actual de la ISS.
- 2) Parseo JSON: convertimos la respuesta a un objeto de R.
-
3) Normalización: seleccionamos solo lo que nos interesa y lo renombramos con un esquema estable:
lat,lon,altitude_km,velocity_kmh, etc. -
4) Tipos correctos: forzamos
as.numeric()porque a veces los JSON llegan como texto y Plotly/DT funcionan mucho mejor si ya lo dejamos como numérico. -
5) Tiempo usable: guardamos el
timestamp“crudo” y también lo convertimos aPOSIXcten UTC (time_utc) para poder dibujar ejes temporales y mostrar la hora en el hover.
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.
- Slider para el refresco (1–10 segundos).
- Start/Stop para arrancar y parar el seguimiento.
- Limpiar para reiniciar la trayectoria.
- Descargar CSV para exportar todos los puntos.
- Log + métricas: velocidad, altitud y número de puntos guardados.
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:
running: un interruptor (TRUE/FALSE) que indica si el tracking está activo.last_msg: un texto corto para mostrar el estado (OK, error, “parado”, etc.).track: una tabla donde vamos acumulando los puntos (lat/lon, hora UTC, altitud, velocidad…).
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):
-
Trayectoria (
lines+markers): pinta todos los puntos acumulados y los conecta con una línea para ver el recorrido. -
Ú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:
- Lectura rápida: vemos si la velocidad se mantiene en un rango razonable o si hay variaciones.
- Control de calidad: si de repente aparece un pico raro o un salto brusco, suele indicar un punto “malo” (fallo de red, respuesta parcial, o un dato anómalo). Es una forma visual de detectar problemas sin mirar la tabla.
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”?
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.
- La órbita de la ISS está inclinada: la latitud sube y baja en cada vuelta.
- Mientras tanto, la Tierra gira debajo, desplazando el “suelo” sobre el que proyectamos la órbita.
- Y al aplanar la esfera en un mapa, la trayectoria se “deforma” un poco, pero se vuelve legible para humanos.
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()
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
- API “Where The ISS At?” (documentación y endpoint del satélite 25544). wheretheiss.at
-
Documentación de
invalidateLater()(Shiny). shiny.posit.co -
Plotly en R – referencia de
scattergeo. plotly.com - DT (R) – documentación oficial (DataTables en Shiny). rstudio.github.io/DT
-
DataTables – opción
scrollX(scroll horizontal). datatables.net