Mapa de calor temporal del COVID‑19 en España con GeoPandas y Folium
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:
date: fecha del registro.province: nombre de la provincia.ine_code: código INE de la provincia (nos sirve para enlazar con el GeoJSON).new_cases/daily_cases: medidas de casos diarios (dependiendo de cuál uses en la práctica).
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:
properties: atributos (por ejemplo, código de provincia, nombre, etc.).geometry: la forma geográfica (Point,LineString,Polygon,MultiPolygon...).
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:
- Convertimos los nulos en 0 (si no hay dato, no aportamos intensidad).
- Recortamos los negativos a 0 (en un heatmap no tiene sentido “intensidad negativa”).
- Filtramos el intervalo de fechas que queremos analizar.
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_CASOSajusta el contraste del mapa: cuanto más grande, más “suave” se verá el heatmap.min(1, intensidad)evita que los picos extremos dominen toda la visualización.- Solo añadimos puntos cuando
casos_diarios > 0, así reducimos ruido y mejoramos el rendimiento.
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:
- Reproducir la serie temporal con un deslizador de fechas.
- Dibujar el heatmap usando nuestro conjunto de puntos
[lat, lon, intensidad]por día. - Aplicar parámetros de visualización (radio, opacidad, gradiente de colores) para mejorar la legibilidad.
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
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:
- Concentración en provincias más pobladas: las zonas con mayor población (y normalmente mayor densidad urbana) tienden a mostrar más intensidad de forma recurrente. Esto sugiere una relación clara entre población/densidad y la propagación del virus (más contacto, más movilidad, más interacción).
- Olas bien marcadas a lo largo del periodo: el mapa no es uniforme en el tiempo. Se distinguen varias fases donde aparecen “zonas calientes”, crecen, se desplazan y luego bajan. En especial, destaca un pico muy grande a finales de 2021 y principios de 2022, que coincide con la ola asociada a la variante Ómicron.
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).
Descarga el notebook
Si quieres replicarlo exactamente igual y jugar con él, aquí tienes el notebook completo listo para ejecutar.
Fuentes y enlaces
- GeoJSON de provincias de España. codeforgermany/click_that_hood
- Dataset COVID por provincias (consolidado). montera34/escovid19data