← Volver

Mapa de calor temporal del COVID‑19 en España con GeoPandas y Folium

Mapa de calor temporal del COVID‑19 (imagen ilustrativa)

Hay datos que, si los miras en una tabla, se vuelven fríos y abstractos. Pero cuando los colocas sobre un mapa y les das tiempo, se convierten en una historia: dónde empezó, cuándo explotó y cómo fue cambiando la presión sobre el territorio. En esta actividad construimos un mapa interactivo para seguir la evolución del COVID-19 en España por provincias desde el 2020-03-01 hasta el 2022-05-01.

La idea es simple pero muy potente: para cada día calculamos una “intensidad” por provincia y la representamos como un mapa de calor con control temporal. Así no vemos una foto fija, sino una película: con un deslizador podemos recorrer todo el periodo y observar cómo aparecen los picos, dónde se concentran y cómo se desplazan las zonas más afectadas a lo largo del tiempo.

1) Los datos: CSV + GeoJSON

En esta actividad trabajamos con dos fuentes de datos que se complementan: por un lado un CSV con la evolución del COVID por provincia y, por otro, un GeoJSON con la forma (geometría) de cada provincia para poder representarla en un mapa.

1.1) El CSV: serie temporal por provincia

El archivo CSV es una tabla “clásica”: cada fila corresponde a una provincia en una fecha concreta. Incluye muchas métricas (casos, hospitalizaciones, UCI, fallecidos, acumulados, medias, etc.). Para nuestro mapa necesitamos elegir una señal que varíe día a día; en esta práctica usamos una columna de casos diarios (por ejemplo new_cases, tras limpiarla) y la convertimos en una intensidad para el mapa de calor.

Las columnas clave para unir y filtrar suelen ser:

1.2) El GeoJSON: geometría de provincias

El GeoJSON contiene la parte “geográfica”: para cada provincia guarda su polígono (o multipolígono) con las coordenadas que delimitan su contorno. Es decir, el CSV nos dice cuántos casos y el GeoJSON nos dice dónde.

GeoJSON es un formato basado en JSON muy común en cartografía web. Representa objetos geográficos como Features (elementos) dentro de una FeatureCollection (colección). Cada Feature tiene:

Por ejemplo, una provincia puede venir como MultiPolygon porque está formada por varias piezas (islas, enclaves o zonas separadas). En GeoJSON, las coordenadas se guardan normalmente en el orden [longitud, latitud]:

{
  "type": "Feature",
  "geometry": {
    "type": "MultiPolygon",
    "coordinates": [
      [
        [
          [3.213645, 39.957514],
          [3.154396, 39.923218],
          ...
        ]
      ]
    ]
  }
}

1.3) ¿Por qué usamos puntos si tenemos polígonos?

Para dibujar un mapa de calor necesitamos puntos, no polígonos. En lugar de colorear toda la provincia, tomamos un punto representativo: el centroide del polígono (un punto por provincia) y le asignamos una intensidad basada en los casos diarios de ese día.

Así, para cada fecha construimos una lista de puntos con el formato:

[
  [lat, lon, intensidad],
  [lat, lon, intensidad],
  ...
]

Y repetimos esa lista para cada día del intervalo. De esta manera, HeatMapWithTime puede reproducir la secuencia completa con el deslizador temporal.

2) Paso a paso del proyecto

Paso 0 — Preparar el entorno

Antes de empezar, vamos a preparar el entorno del notebook: instalamos (si hace falta) las librerías y cargamos los imports que usaremos durante toda la actividad.

# Instalación de librerías (si no las tenéis)
%pip install folium plotly pandas
%pip install --upgrade nbformat

import pandas as pd
import geopandas as gpd
import numpy as np
import folium
from folium import plugins
import plotly.express as px

# Configuración para silenciar advertencias estéticas
import warnings
warnings.filterwarnings('ignore')

print("Entorno Geoespacial configurado correctamente.")

Paso 1 — Cargar el mapa de provincias

Ahora vamos a cargar un GeoJSON con las provincias y a calcular su centroide. Ese punto nos servirá como “ancla” para colocar cada provincia en el mapa de calor. Además, convertimos el código de provincia a entero para poder unirlo con el dataset de COVID sin sustos ni errores de tipos.

url_mapa = "https://raw.githubusercontent.com/codeforgermany/click_that_hood/main/public/data/spain-provinces.geojson"
provincias = gpd.read_file(url_mapa)

provincias["cod_prov"] = provincias["cod_prov"].astype(int)
provincias["centroide"] = provincias.geometry.centroid
Nota: calcular centroides sobre coordenadas geográficas (EPSG:4326) puede dar avisos de precisión. Para una visualización como esta es suficiente, pero si quieres máxima exactitud puedes reproyectar a un CRS métrico antes de calcularlos.

Paso 2 — Descargar y limpiar el dataset de COVID

Ahora descargamos el CSV y hacemos la parte más importante: dejar los datos listos para visualizar. En este dataset es normal encontrar valores nulos (días sin reporte) y también valores negativos (correcciones que se aplican cuando se revisan los datos). Si los dejamos tal cual, el mapa de calor puede quedar distorsionado.

Para evitarlo, creamos una columna propia llamada casos_diarios donde:

df_covid["date"] = pd.to_datetime(df_covid["date"])
df_covid["ine_code"] = df_covid["ine_code"].astype(int)
df_covid = df_covid.sort_values(["ine_code", "date"])

df_covid["casos_diarios"] = df_covid["new_cases"].fillna(0).clip(lower=0)

start_date = "2020-03-01"
end_date = "2022-05-01"
df_final = df_covid.loc[(df_covid["date"] >= start_date) & (df_covid["date"] <= end_date)].copy()

Paso 3 — Unir geometría + datos (merge)

Con el GeoJSON (geometría) por un lado y el CSV (métricas de COVID) por otro, ahora toca conectarlos. La idea es simple: queremos que cada fila de datos diarios “sepa” qué provincia es en el mapa.

Para conseguirlo hacemos un merge usando el identificador común: cod_prov en el GeoJSON y ine_code en el CSV. Al unirlos, obtenemos una tabla geoespacial donde cada registro incluye la geometría de la provincia y sus valores de COVID para esa fecha.

A partir de aquí ya podemos calcular el centroide y preparar los puntos [lat, lon, intensidad] que necesita el mapa de calor temporal.

mapa_datos = provincias.merge(df_final, left_on="cod_prov", right_on="ine_code", how="inner")

Paso 4 — Preparar el formato para HeatMapWithTime

Ahora transformamos nuestros datos al formato exacto que necesita el mapa de calor temporal. HeatMapWithTime no trabaja con una tabla “normal”, sino con una lista de listas: una lista por cada día, y dentro de cada día una lista de puntos con esta estructura: [lat, lon, intensidad].

La intensidad la calculamos a partir de los casos_diarios. Pero aparece un problema típico en este periodo: hay días con picos extremadamente altos (por ejemplo, durante la ola asociada a la variante Ómicron), y esos picos pueden “comerse” visualmente el resto y dejar el mapa casi uniforme. Para controlarlo usamos una constante ESCALA_DE_CASOS (un factor de normalización) y además limitamos la intensidad máxima a 1.

Ojo con el orden de coordenadas: en GeoJSON es habitual ver coordenadas como [lon, lat] (longitud, latitud), pero en el heatmap necesitamos [lat, lon]. Por eso, al construir cada punto usamos centroide.y como latitud y centroide.x como longitud. Si lo invertimos, los puntos se dibujan en lugares incorrectos (a veces incluso fuera del mapa).

ESCALA_DE_CASOS = 500

datos_tiempo = []
indice_tiempo = []
dias = sorted(mapa_datos["date"].unique())

for dia in dias:
    datos_dia = mapa_datos[mapa_datos["date"] == dia]
    puntos_dia = []

    for _, row in datos_dia.iterrows():
        if row["casos_diarios"] > 0:
            intensidad = row["casos_diarios"] / ESCALA_DE_CASOS
            intensidad = min(1, intensidad)
            puntos_dia.append([row["centroide"].y, row["centroide"].x, intensidad])

    datos_tiempo.append(puntos_dia)
    indice_tiempo.append(pd.to_datetime(dia).strftime("%Y-%m-%d"))

Paso 5 — Crear el mapa, añadir la capa temporal y exportar

Ya tenemos los datos en el formato correcto, así que ahora montamos la visualización final. Primero creamos un mapa base centrado en España y usamos un tema oscuro para que el mapa de calor destaque mejor (las zonas con mayor intensidad se ven con más contraste).

Después añadimos la capa HeatMapWithTime, que es la responsable de:

m_final = folium.Map(location=[40.0, -3.7], zoom_start=6, tiles="CartoDB dark_matter")

plugins.HeatMapWithTime(
    data=datos_tiempo,
    index=indice_tiempo,
    radius=35,
    auto_play=True,
    max_opacity=0.8,
    min_opacity=0.2,
    gradient={0.0:"blue", 0.3:"lime", 0.6:"orange", 1.0:"red"}
).add_to(m_final)

m_final.save("covid.html")

3) Resultado

Si quieres reproducirlo rápidamente, aquí tienes el script completo (solo necesitas tener instalados pandas, geopandas y folium):

import pandas as pd
import geopandas as gpd
import folium
from folium import plugins

# 1) Descargar el mapa de provincias (GeoJSON)
print("Descargando mapa de provincias...")
url_mapa = "https://raw.githubusercontent.com/codeforgermany/click_that_hood/main/public/data/spain-provinces.geojson"
provincias = gpd.read_file(url_mapa)

# Convertimos a enteros para evitar errores al unir con el dataset COVID
provincias["cod_prov"] = provincias["cod_prov"].astype(int)

# Calculamos el centroide de cada provincia (usaremos puntos para el mapa de calor)
provincias["centroide"] = provincias.geometry.centroid

# 2) Descargar dataset COVID por provincias
print("Descargando dataset de COVID... (puede tardar un poco)")
url_dataset = "https://raw.githubusercontent.com/montera34/escovid19data/master/data/output/covid19-provincias-spain_consolidated.csv"
df_covid = pd.read_csv(url_dataset)

# 3) Preparación/limpieza
df_covid["date"] = pd.to_datetime(df_covid["date"])
df_covid["ine_code"] = df_covid["ine_code"].astype(int)
df_covid = df_covid.sort_values(["ine_code", "date"])

# Creamos una columna más representativa y controlamos nulos/negativos
df_covid["casos_diarios"] = df_covid["new_cases"].fillna(0).clip(lower=0)

# Filtramos el intervalo de tiempo de la práctica
start_date = "2020-03-01"
end_date = "2022-05-01"
intervalo = (df_covid["date"] >= start_date) & (df_covid["date"] <= end_date)
df_final = df_covid.loc[intervalo].copy()

# 4) Unimos geometría (provincias) + datos (COVID) por código INE
mapa_datos = provincias.merge(df_final, left_on="cod_prov", right_on="ine_code", how="inner")
print(f"Datos preparados. Filas totales: {len(mapa_datos)}")

# 5) Transformación a formato HeatMapWithTime: lista por día -> [lat, lon, intensidad]
ESCALA_DE_CASOS = 500  # ajusta si quieres más/menos contraste

datos_tiempo = []
indice_tiempo = []
dias = sorted(mapa_datos["date"].unique())

for dia in dias:
    datos_dia = mapa_datos[mapa_datos["date"] == dia]
    puntos_dia = []

    for _, row in datos_dia.iterrows():
        if row["casos_diarios"] > 0:
            intensidad = row["casos_diarios"] / ESCALA_DE_CASOS
            intensidad = min(1, intensidad)  # limitamos para que no se “coma” el resto del mapa
            puntos_dia.append([row["centroide"].y, row["centroide"].x, intensidad])

    datos_tiempo.append(puntos_dia)
    indice_tiempo.append(pd.to_datetime(dia).strftime("%Y-%m-%d"))

# 6) Mapa final + exportación a HTML
m_final = folium.Map(location=[40.0, -3.7], zoom_start=6, tiles="CartoDB dark_matter")

plugins.HeatMapWithTime(
    data=datos_tiempo,
    index=indice_tiempo,
    radius=35,
    auto_play=True,
    max_opacity=0.8,
    min_opacity=0.2,
    name="Covid 19 Evolución",
    gradient={
        0.0: "blue",
        0.3: "lime",
        0.6: "orange",
        1.0: "red",
    },
).add_to(m_final)

print("Se ha generado el mapa: covid.html")
m_final.save("covid.html")

m_final
Abrir el mapa en una pestaña

4) Conclusiones

Una vez tenemos el mapa en movimiento, no solo vemos “manchas de color”: estamos viendo un resumen visual de cómo evoluciona la incidencia en el tiempo y cómo se concentra geográficamente. Hay dos ideas principales que se aprecian bastante bien:

En resumen: el heatmap temporal nos permite entender el patrón (dónde y cuándo) sin quedarnos solo con una tabla. Y, aunque esta visualización no prueba causalidad por sí sola, sí sirve para detectar rápidamente concentraciones, olas y momentos críticos que luego podríamos analizar con métricas más formales (por ejemplo, casos por 100.000 habitantes, comparativas entre provincias o correlaciones con movilidad/densidad).

📓 Notebook incluido

Descarga el notebook

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

Fuentes y enlaces

  1. GeoJSON de provincias de España. codeforgermany/click_that_hood
  2. Dataset COVID por provincias (consolidado). montera34/escovid19data