Visualización de rutas romanas con GeoPandas y Folium
Las calzadas romanas fueron mucho más que “carreteras”: eran la infraestructura que hacía posible que el Imperio funcionase. Por ellas viajaban ejércitos, comercio, correos… y, en el fondo, información. Lo interesante es que hoy podemos mirar esa red con ojos modernos: como un problema de datos geoespaciales listo para explorar y visualizar.
En este tutorial vamos a ir directo a lo útil: montamos el entorno, descargamos el dataset de Itiner-e, entendemos su estructura y lo convertimos en un mapa interactivo con GeoPandas + Folium. La idea es terminar con una base sólida (y reutilizable) sobre la que luego podremos añadir filtros, estilos y análisis sin rehacer nada.
1) Preparar el entorno
Lo único que necesitamos aquí son 4 librerías: pandas (tablas), geopandas (geodatos), shapely (geometrías) y folium (mapa interactivo).
pip install pandas geopandas shapely folium
Importamos todo y comprobamos que está ok:
import pandas as pd
import geopandas as gpd
from shapely.geometry import shape
import folium
print("OK ✅", pd.__version__, gpd.__version__)
2) Preparar los datos
El dataset que usamos sale de Itiner-e (lo tienes enlazado en el notebook). Desde su página de descarga nos bajamos el archivo de segmentos de ruta en formato NDJSON.
NDJSON significa “un JSON por línea”: cada línea del fichero es un feature (estilo GeoJSON) con dos partes importantes:
geometry: la geometría del tramo (la línea que dibujaremos en el mapa).properties: metadatos del tramo (tipo de vía, nombre si existe, longitud, certeza, etc.).
En el notebook el archivo se llama route-segments-all-1766580709777.ndjson (el número suele ser un timestamp,
así que si a ti te sale otro, simplemente cambia el PATH).
Colócalo en la misma carpeta que el notebook para que se lea sin rutas raras.
# Constante del fichero (tal cual en el notebook)
PATH = "route-segments-all-1766580709777.ndjson"
# Cargamos el NDJSON: una línea = un feature
df = pd.read_json(PATH, lines=True)
# La información útil vive en "properties", así que la aplanamos a columnas
props = pd.json_normalize(df["properties"])
df.head(), props.head()
A partir de aquí ya lo tenemos listo: df conserva la geometría en la columna geometry y
props nos deja los metadatos en columnas “normales” para poder filtrar y estilizar después.
En el siguiente paso convertiremos todo esto a GeoDataFrame y lo pintaremos en un mapa.
3) Entender el formato
Antes de pintar nada, vamos a echar un vistazo a lo que acabamos de cargar. La idea es simple:
ver qué columnas trae y qué metadatos hay dentro de properties.
Paso 1 — Leer el NDJSON
PATH = "route-segments-all-1766580709777.ndjson" # mismo nombre que en el notebook
df = pd.read_json(PATH, lines=True)
print("Dimensiones:", df.shape)
print("Columnas:", list(df.columns))
Lo normal es encontrar columnas como id, type, properties y geometry.
La geometría nos servirá para dibujar líneas en el mapa, pero lo “interesante” para filtrar y dar estilo suele estar dentro
de properties.
Paso 2 — Aplanar properties a columnas
props = pd.json_normalize(df["properties"])
print("Campos en properties:", len(props.columns))
print(props.columns[:12]) # vistazo rápido
A partir de aquí trabajaremos con dos cosas:
df["geometry"]para las formas (las líneas de cada tramo).propspara los metadatos (tipo de tramo, nombre, longitud, certeza, fechas…).
Campos útiles para empezar
name: nombre del tramo (si existe).type: tipología (Main Road, Secondary Road, etc.).segmentCertainty: certeza del trazado (Certain, Conjectured, Hypothetical…).lowerDate/upperDate: rango temporal (cuando está disponible)._lengthInKm: longitud del segmento.
Con esto entendido, ya podemos convertir la geometría a objetos de shapely y crear un GeoDataFrame
para visualizar los tramos en un mapa.
4) Primera visualización: mapa base interactivo
Ahora sí: vamos a convertir los datos en algo que podamos dibujar. La idea es:
(1) pasar la columna geometry a objetos de shapely,
(2) construir un GeoDataFrame con los metadatos (props) y esa geometría,
y (3) recorrer cada tramo y pintarlo en un mapa con Folium.
Paso 1 — Crear el GeoDataFrame
GeoPandas necesita dos cosas: una tabla (nuestros metadatos) y una columna especial geometry
con geometrías reales. El crs="EPSG:4326" indica que estamos en lat/lon (coordenadas geográficas).
# props: columnas "planas" (name, type, _lengthInKm, segmentCertainty, etc.)
# df["geometry"]: geometría en formato GeoJSON (dict). La convertimos a shapely con shape()
gdf = gpd.GeoDataFrame(
props,
geometry=df["geometry"].apply(shape),
crs="EPSG:4326"
)
Paso 2 — Configurar el mapa y el estilo
Vamos a centrar el mapa en Roma y usar un fondo limpio (CartoDB positron), que suele funcionar muy bien
para overlays de líneas. Además definimos una paleta para colorear por tipo de tramo.
from shapely.geometry import LineString, MultiLineString
ROMA = (41.9028, 12.4964)
PALETTE = {
"Main Road": "#d73027",
"Secondary Road": "#fc8d59",
"River": "#1f9e89",
"Sea Lane": "#2c7fb8",
}
def color_tramo(t: str) -> str:
return PALETTE.get(t, "#444444")
m = folium.Map(location=ROMA, zoom_start=5, tiles="CartoDB positron")
Si quieres generar una versión más ligera (por ejemplo solo Mediterráneo), puedes filtrar por un bounding box. Es muy útil si el navegador se resiente al cargar miles de líneas:
# Ejemplo (Mediterráneo aproximado): [minLon:maxLon, minLat:maxLat]
# gdf = gdf.cx[-10:45, 30:55]
Paso 3 — Dibujar cada tramo en el mapa
for _, row in gdf.iterrows():
geom = row.geometry
tipo = row.get("type", None)
nombre = row.get("name", "(sin nombre)")
km = row.get("_lengthInKm", None)
tooltip = f"{nombre} | {tipo}"
if km is not None and km == km:
tooltip += f" | {km:.2f} km"
if isinstance(geom, LineString):
lines = [geom]
elif isinstance(geom, MultiLineString):
lines = list(geom.geoms)
else:
continue
for line in lines:
coords = [[lat, lon] for lon, lat in line.coords]
folium.PolyLine(
locations=coords,
weight=3,
color=color_tramo(tipo),
opacity=0.85,
tooltip=tooltip
).add_to(m)
Paso 4 — Guardar el HTML del mapa
OUTPUT = "tecnicas_visualización/mapa_rutas_romanas_base.html"
m.save(OUTPUT)
print("Mapa guardado en:", OUTPUT)
5) Versión con filtro: activar/desactivar tipos de ruta
El mapa base está genial para una vista general, pero cuando hay muchos tramos se mezcla todo y cuesta interpretar
qué estamos viendo. Aquí generamos una versión con “filtro” usando el control de capas de Folium:
podremos encender y apagar tramos según su type.
Paso 1 — Crear una capa por cada tipo (FeatureGroup)
capas = {}
tipos = gdf["type"].dropna().unique()
for t in tipos:
capas[t] = folium.FeatureGroup(name=t)
Paso 2 — Dibujar cada tramo y añadirlo a su capa
folium.PolyLine(
locations=ruta,
weight=3,
color=color,
opacity=0.85,
tooltip=tooltip_texto
).add_to(capas[tipo])
Paso 3 — Añadir todas las capas al mapa
for t in capas:
capas[t].add_to(m)
Paso 4 — Activar el panel de filtro (LayerControl)
folium.LayerControl(collapsed=True).add_to(m)
Mapa interactivo (con filtro)
6) Calzadas romanas vs carreteras modernas (OpenStreetMap)
Vale, ya tenemos el mapa de rutas romanas funcionando. Ahora vamos a hacer el primer mini-proyecto “de verdad”: comparar calzadas romanas con carreteras modernas para ver hasta qué punto siguen coincidiendo los ejes de movilidad.
Importante: el dataset de Itiner-e es grande, y la red moderna de OpenStreetMap también. Por eso en el notebook lo hacemos a nivel local (alrededor de Roma), en vez de intentar pintarlo todo de golpe. Así el mapa carga bien y la comparación se entiende mejor.
Paso 1 — Definir el “área de estudio” (radio alrededor de Roma)
Usamos el centro ROMA (ya lo tienes definido arriba en constantes) y un radio en metros. En el notebook se usa
30 km:
# Carreteras modernas alrededor de Roma
lat, lon = ROMA
dist_m = 30000 # 30 km
Paso 2 — Descargar carreteras modernas con OSMnx (filtrando solo las importantes)
Para comparar con infraestructura moderna vamos a tirar de OpenStreetMap, pero no descargando un archivo manualmente, sino usando OSMnx. OSMnx hace la petición online (normalmente vía Overpass) y nos devuelve un grafo con nodos y aristas de la red vial de la zona que le pidamos.
Aquí está el “truco” para que el mapa sea legible: en vez de traernos absolutamente todas las calles,
filtramos por tipos de vía usando la etiqueta highway de OSM. En nuestro caso nos quedaremos con:
motorway, trunk, primary y secondary.
import osmnx as ox # 🔥 librería para descargar/red vial de OpenStreetMap (OSM)
# Filtramos carreteras y caminos importantes
custom_filter = '["highway"~"motorway|trunk|primary|secondary"]'
G = ox.graph_from_point(
(lat, lon),
dist=dist_m, # radio en metros alrededor del punto
custom_filter=custom_filter,
simplify=True # simplifica geometrías (menos ruido, más limpio)
)
Paso 3 — Convertir el grafo a GeoDataFrame de líneas
modern_edges = ox.graph_to_gdfs(G, nodes=False, edges=True).reset_index(drop=True)
modern_edges = modern_edges.set_crs("EPSG:4326", allow_override=True)
print("Carreteras modernas importantes:", len(modern_edges))
Paso 4 — Superponer ambas capas en un mismo mapa
m = folium.Map(location=ROMA, zoom_start=9, tiles="CartoDB positron")
def style_roman(feat):
t = feat["properties"].get("type", "")
return {"color": PALETTE.get(t, "#444444"), "weight": 3, "opacity": 0.85}
folium.GeoJson(
gdf,
name="Rutas romanas",
style_function=style_roman
).add_to(m)
folium.GeoJson(
modern_edges,
name=f"Carreteras modernas (OSM) ≤ {dist_m/1000:.0f} km",
style_function=lambda feat: {"color": "#666666", "weight": 2, "opacity": 0.35}
).add_to(m)
folium.LayerControl(collapsed=False).add_to(m)
out = "tecnicas_visualización/romanas_vs_modernas_radio_roma.html"
m.save(out)
print("Guardado:", out)
Mapa interactivo (comparación)
Conclusión
Al superponer las rutas romanas con la red moderna alrededor de Roma se ve algo bastante lógico… pero impacta cuando lo visualizas: gran parte de los ejes principales romanos siguen marcando el “esqueleto” de la movilidad actual. Muchos tramos de Main Road coinciden con corredores de carreteras modernas, lo que sugiere una continuidad clara: se han ido adaptando y reforzando con el tiempo, pero el trazado base en muchos casos se mantiene.
En cambio, los tramos secundarios romanos muestran menos continuidad. Algunos se han reutilizado (o han quedado como vías locales), pero en general aparecen más fragmentados, lo cual tiene sentido: la red moderna prioriza los corredores de alta conectividad. Además, en los alrededores de los ríos se aprecia otro patrón: hoy las carreteras tienden a acompañar el valle y las zonas más transitables, mientras que en el dataset romano los ríos aparecen como rutas de transporte propias.
En conjunto, la comparación deja una idea potente: la infraestructura no se inventa de cero. El Imperio romano no solo construyó caminos, sino que fijó una lógica territorial que, en muchos puntos, sigue condicionando cómo nos movemos casi dos mil años después.
7) Naufragios y rutas marinas
En este mini-proyecto vamos a cruzar dos capas: (1) las rutas marinas del dataset de Itiner-e
(los tramos con type = "Sea Lane") y (2) una capa de naufragios del dataset de OxREP.
Importante: las coordenadas de muchos naufragios en OxREP aparecen difuminadas o aproximadas. Esto es intencional: sirve para proteger los yacimientos y reducir el riesgo de expolio y saqueo arqueológico. Por eso, aquí no tiene sentido interpretarlo como “puntos exactos”, sino como zonas de concentración. La visualización correcta es un mapa de calor (heatmap): nos interesa el patrón espacial, no la localización precisa.
Paso 0 — Exploración rápida del Excel (OxREP) antes de filtrar
Antes de ponernos a filtrar y pintar, hacemos un análisis previo del Excel para entender qué trae exactamente: qué hojas incluye el fichero, cuántos registros hay, qué columnas vienen y cómo están escritos los periodos. Esto nos evita ir a ciegas (y nos explica por qué luego normalizamos y filtramos).
Para eso ejecutamos:
# Ver las hojas
xls = pd.ExcelFile(PATH_NAUFRAGIOS)
print("Hojas disponibles:")
for i, s in enumerate(xls.sheet_names):
print(f" {i}: {s}")
# Cargamos la primera hoja
df = pd.read_excel(PATH_NAUFRAGIOS, sheet_name=xls.sheet_names[0])
print("\nNúmero de filas:", len(df))
print("Número de columnas:", len(df.columns))
print("\nColumnas:")
for c in df.columns:
print("-", repr(c))
print("\nPrimeras 5 filas:")
display(df.head())
# Ver todos los periodos distintos
periodos = (
df["Period"]
.dropna()
.astype(str)
.str.strip()
.sort_values()
.unique()
)
print("Periodos disponibles:")
for p in periodos:
print("-", p)
La salida nos dice dos cosas clave:
-
El archivo trae 3 hojas:
Shipwrecks,AmphoraeyShipwreck Objects. Para este mini-proyecto nos interesa Shipwrecks, porque es la que contiene coordenadas + periodo. -
En
Shipwreckshay 1818 filas y 32 columnas. O sea: hay bastante información, pero para el heatmap solo vamos a usar una parte mínima.
Estas son las columnas que vemos en el Excel (tal cual aparecen):
Latitude/Longitude— coordenadas del naufragio (ojo: a veces aproximadas/difuminadas).Period— etiqueta de periodo histórico/cultural (la clave para filtrar “lo romano”).Earliest Date/Latest Date— rango de datación cuando está disponible.Site Name,Wreck Name,Wreck ID,Strauss ID… — identificadores / nombres.Sea area,Country,Region— contexto geográfico adicional.Min depth,Max depth,Depth— información de profundidad.-
Campos de carga/arqueología como
Amphorae,Marble,Blocks,Hull remains,Ship equipment, etc.
Y lo más importante: al listar los periodos distintos vemos que Period no es “limpio” ni uniforme.
Aparecen valores como Roman, Roman Republic, Late Roman, combinaciones con barras
(Hellenistic/Roman), mayúsculas mezcladas, espacios raros e incluso cosas como ? o 6.
Por eso, en el Paso 3 hacemos:
normalización (strip() + casefold()) y luego filtramos con un conjunto de etiquetas aceptadas.
Paso 1 — Dibujar las rutas marinas (Sea Lane) como líneas
Partimos del gdf de Itiner-e que ya teníamos creado (con la geometría en shapely).
Aquí la idea es simple: no nos interesan las rutas terrestres para este mini-proyecto, así que
nos quedamos solo con los tramos cuyo type es "Sea Lane".
Además, en vez de pintar estas líneas directamente sobre el mapa, las metemos dentro de un
FeatureGroup (fg_routes). ¿Por qué? Porque así luego podemos
activar/desactivar la capa con LayerControl y el mapa se vuelve mucho más útil para explorar.
En el bucle construimos la lista de coordenadas en formato Folium ([lat, lon]) a partir de las coordenadas
de Shapely ((lon, lat)). Y añadimos un tooltip con el nombre (si existe) y el tipo,
para que al pasar el ratón podamos identificar cada tramo.
m = folium.Map(location=ROMA, zoom_start=6, tiles="CartoDB positron")
# Solo cogemos las rutas marinas ya que no nos interesan las terrestres
gdf_sea = gdf[gdf["type"].astype(str).str.strip() == "Sea Lane"].copy()
fg_routes = folium.FeatureGroup(name="Rutas marinas (Sea Lane)")
for _, row in gdf_sea.iterrows():
tipo = row.get("type", "")
tooltip_texto = ""
if "name" in gdf_sea.columns and pd.notna(row.get("name", None)):
tooltip_texto = str(row["name"])
tooltip_texto += (" | " if tooltip_texto else "") + str(tipo)
coords = list(row.geometry.coords)
ruta = [[y, x] for x, y in coords]
folium.PolyLine(
locations=ruta,
weight=3,
color=PALETTE.get(tipo, "#444444"),
opacity=0.85,
tooltip=tooltip_texto
).add_to(fg_routes)
fg_routes.add_to(m)
Paso 2 — Cargar el dataset de naufragios (OxREP) desde Excel
Ahora cargamos la capa de naufragios desde el Excel de OxREP (hoja "Shipwrecks").
Aquí hay un detalle importante: este dataset incluye muchas columnas, pero para el mapa de calor lo mínimo imprescindible es
tener coordenadas. Por eso eliminamos cualquier fila que no tenga Latitude o Longitude.
También limpiamos el campo Period con strip(). Esto nos evita problemas típicos de texto
(espacios al principio/final) cuando luego filtremos por periodos romanos. La idea es dejarlo “uniforme” antes de comparar.
# Naufragios
naufragios = pd.read_excel(PATH_NAUFRAGIOS, sheet_name="Shipwrecks")
naufragios["Period"] = naufragios["Period"].astype(str).str.strip()
naufragios = naufragios.dropna(subset=["Latitude", "Longitude"]).copy()
Paso 3 — Filtrar solo periodos romanos
El Excel incluye naufragios de muchísimas épocas (prehistoria, griega, romana, medieval...). Como en este mini-proyecto estamos comparando con las Sea Lanes del mundo romano, nosotros queremos quedarnos con una pregunta concreta: ¿dónde se concentran los naufragios de época romana?
Para eso definimos un conjunto (set) con todas las etiquetas “romanas” que aparecen en el dataset (porque no viene
con un nombre único y consistente: hay variantes, barras, espacios dobles, signos de interrogación, etc.).
Y aquí está el truco: en lugar de crear columnas nuevas, normalizamos “al vuelo” con
casefold() (más robusto que lower()) para comparar sin que nos afecten mayúsculas/minúsculas.
Después filtramos con isin. Al final imprimimos el recuento para verificar que el filtro está funcionando.
PERIODOS_ROMANOS = {
"hellenistic/ roman",
"hellenistic/roman",
"late imperial",
"late rep/imperial",
"late rep/early imp",
"late rep/early imperial",
"late republic",
"late republic-early imperial",
"late republican",
"late republican/early imperial",
"late roman",
"late roman/ byzantine",
"late roman?",
"republican",
"rom rep/imperial",
"roma imperial",
"roman",
"roman imperial",
"roman empire",
"roman imperial",
"roman late empire?",
"roman rep/imp",
"roman rep/imperial",
"roman repbulic",
"roman republic",
"roman republic/imperial",
"roman republican",
"roman period",
"roman period?",
"roman?",
"early roman imperial",
}
p = naufragios["Period"].astype(str).str.strip().str.casefold()
naufragios = naufragios[p.isin(PERIODOS_ROMANOS)].copy()
print("Naufragios romanos:", len(naufragios))
Paso 4 — Heatmap de naufragios + guardado del HTML
Como las coordenadas del dataset están difuminadas o aproximadas por seguridad (para reducir el riesgo de expolio),
en vez de dibujar marcadores uno a uno construimos un HeatMap a partir de pares (lat, lon).
Metemos el heatmap en su propio FeatureGroup para poder alternarlo con las rutas marinas.
Después activamos LayerControl (para encender/apagar capas) y guardamos el resultado en un HTML.
# Heatmap
heat_data = naufragios[["Latitude", "Longitude"]].values.tolist()
fg_heat = folium.FeatureGroup(name="Heatmap naufragios (Roman)")
HeatMap(
heat_data,
radius=12,
blur=18,
min_opacity=0.35,
max_zoom=8
).add_to(fg_heat)
fg_heat.add_to(m)
folium.LayerControl(collapsed=True).add_to(m)
out = "tecnicas_visualización/mapa_rutas_marinas_naufragios.html"
m.save(out)
print("Guardado:", out)
Resultado
PERIODOS_ROMANOS = {
"hellenistic/ roman",
"hellenistic/roman",
"late imperial",
"late rep/imperial",
"late rep/early imp",
"late rep/early imperial",
"late republic",
"late republic-early imperial",
"late republican",
"late republican/early imperial",
"late roman",
"late roman/ byzantine",
"late roman?",
"republican",
"rom rep/imperial",
"roma imperial",
"roman",
"roman imperial",
"roman empire",
"roman imperial",
"roman late empire?",
"roman rep/imp",
"roman rep/imperial",
"roman repbulic",
"roman republic",
"roman republic/imperial",
"roman republican",
"roman period",
"roman period?",
"roman?",
"early roman imperial",
}
m = folium.Map(location=ROMA, zoom_start=6, tiles="CartoDB positron")
# Solo cogemos las rutas marinas ya que no nos interesan las terrestres
gdf_sea = gdf[gdf["type"].astype(str).str.strip() == "Sea Lane"].copy()
fg_routes = folium.FeatureGroup(name="Rutas marinas (Sea Lane)")
for _, row in gdf_sea.iterrows():
tipo = row.get("type", "")
tooltip_texto = ""
if "name" in gdf_sea.columns and pd.notna(row.get("name", None)):
tooltip_texto = str(row["name"])
tooltip_texto += (" | " if tooltip_texto else "") + str(tipo)
coords = list(row.geometry.coords)
ruta = [[y, x] for x, y in coords]
folium.PolyLine(
locations=ruta,
weight=3,
color=PALETTE.get(tipo, "#444444"),
opacity=0.85,
tooltip=tooltip_texto
).add_to(fg_routes)
fg_routes.add_to(m)
# Naufragios
naufragios = pd.read_excel(PATH_NAUFRAGIOS, sheet_name="Shipwrecks")
naufragios["Period"] = naufragios["Period"].astype(str).str.strip()
naufragios = naufragios.dropna(subset=["Latitude", "Longitude"]).copy()
p = naufragios["Period"].astype(str).str.strip().str.casefold()
naufragios = naufragios[p.isin(PERIODOS_ROMANOS)].copy()
print("Naufragios romanos:", len(naufragios))
# Heatmap
heat_data = naufragios[["Latitude", "Longitude"]].values.tolist()
fg_heat = folium.FeatureGroup(name="Heatmap naufragios (Roman)")
HeatMap(
heat_data,
radius=12,
blur=18,
min_opacity=0.35,
max_zoom=8
).add_to(fg_heat)
fg_heat.add_to(m)
m
out = "tecnicas_visualización/mapa_rutas_marinas_naufragios.html"
m.save(out)
print("Guardado:", out)
Rutas marinas + naufragios
Versión mejorada: heatmaps por sub-periodo + leyenda
El primer heatmap (todo “Roman” junto) nos da una vista general, pero mezcla en una misma capa naufragios de República, Alto Imperio y Bajo Imperio. La mejora que hacemos en el notebook es separar por sub-periodos y crear un heatmap por grupo, cada uno con un gradiente distinto. Así, en el mapa podemos activar/desactivar capas y ver si el patrón espacial cambia según la época.
Además, como cada grupo usa colores diferentes, añadimos una leyenda fija (un panel HTML) para poder interpretar rápidamente qué color corresponde a cada sub-periodo.
Paso A — Definir grupos de periodos (y por qué)
Aquí lo importante es que Period en OxREP no es uniforme: hay muchas variantes para “lo romano”.
Por eso, primero normalizamos el texto (en el paso anterior ya tenemos period_norm) y luego definimos
un diccionario GRUPOS:
- La clave del diccionario es el nombre “bonito” que saldrá en el selector de capas de Folium.
-
El valor es un conjunto (
set) con las etiquetas exactas (ya normalizadas) que queremos considerar dentro de ese grupo.
GRUPOS = {
"República (Roman Republic)": {
"roman republic",
},
"Imperio temprano (Early Imperial)": {
"roman early imperial",
},
"Alto Imperio (High Imperial)": {
"roman high imperial",
"roman imperial",
"roman rep/imperial",
},
"Bajo Imperio (Late Imperial / Late Roman)": {
"roman late imperial",
"late roman",
"roman/late roman",
"late roman/early byzantine",
"roman/byzantine",
"roman imperial/byzantine",
},
"General (Roman)": {
"roman",
}
}
Paso B — Asignar un gradiente (colores) a cada grupo
Folium HeatMap permite definir un gradient (un diccionario de “intensidad → color”).
Nosotros lo usamos para que cada sub-periodo tenga una identidad visual clara. La idea práctica es:
0.4= intensidad baja (zonas con poca densidad)0.7= intensidad media1.0= intensidad alta (zonas más concentradas)
GRADIENTES = {
"República (Roman Republic)": {0.4: "#fdd49e", 0.7: "#fc8d59", 1.0: "#d7301f"},
"Imperio temprano (Early Imperial)": {0.4: "#c7e9b4", 0.7: "#7fcdbb", 1.0: "#2c7fb8"},
"Alto Imperio (High Imperial)": {0.4: "#d9f0a3", 0.7: "#addd8e", 1.0: "#31a354"},
"Bajo Imperio (Late Imperial / Late Roman)": {0.4: "#e0ecf4", 0.7: "#9ebcda", 1.0: "#8856a7"},
"General (Roman)": {0.4: "#fee0d2", 0.7: "#fc9272", 1.0: "#de2d26"},
}
Paso C — Construir un heatmap por grupo (capas) y añadir el selector
Ahora montamos un mapa nuevo (m2) y reutilizamos la capa de rutas marinas (fg_routes).
Después recorremos GRUPOS:
-
Filtramos
naufragiosquedándonos con los que estén dentro del conjunto de periodos del grupo (isin(periodos)). - Si el subconjunto está vacío, lo saltamos para no crear capas “muertas”.
-
Convertimos a lista
[[lat, lon], ...]y creamos unHeatMapcon el gradiente del grupo. -
Metemos cada heatmap en un
FeatureGrouppara poder activar/desactivar por sub-periodo.
# Mapa base (nuevo)
m2 = folium.Map(location=ROMA, zoom_start=6, tiles="CartoDB positron")
fg_routes.add_to(m2)
# Heatmap por grupo (una capa por sub-periodo)
for grupo, periodos in GRUPOS.items():
subset = naufragios[naufragios["period_norm"].isin(periodos)]
if subset.empty:
continue
heat_data = subset[["Latitude", "Longitude"]].values.tolist()
fg = folium.FeatureGroup(name=f"Naufragios: {grupo} ({len(heat_data)})")
HeatMap(
heat_data,
radius=12,
blur=18,
max_zoom=6,
gradient=GRADIENTES.get(grupo, None)
).add_to(fg)
fg.add_to(m2)
# Selector de capas (para activar/desactivar grupos)
folium.LayerControl(collapsed=True).add_to(m2)
Paso D — Añadir una leyenda fija (HTML) para interpretar colores
Como el selector de capas no explica por sí solo qué color estamos usando, añadimos una leyenda “anclada” al mapa.
Para eso usamos Template y MacroElement (Branca), que permiten inyectar HTML/CSS dentro
del HTML final del mapa de Folium.
from branca.element import Template, MacroElement
legend_html = """
{% macro html(this, kwargs) %}
<div style="
position: fixed;
bottom: 18px;
left: 18px;
z-index: 9999;
pointer-events: none;
">
<div style="
pointer-events: auto;
background: rgba(255,255,255,0.96);
border: 1px solid rgba(0,0,0,.18);
border-radius: 10px;
padding: 10px 12px;
box-shadow: 0 10px 24px rgba(15,23,42,.18);
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
font-size: 13px;
line-height: 1.25;
max-width: 240px;
">
<div style="font-weight:800; margin:0 0 8px;">Leyenda (naufragios)</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<div><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:#d7301f;margin-right:8px;vertical-align:middle;"></span>República</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:#2c7fb8;margin-right:8px;vertical-align:middle;"></span>Imperio temprano</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:#31a354;margin-right:8px;vertical-align:middle;"></span>Alto Imperio</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:#8856a7;margin-right:8px;vertical-align:middle;"></span>Bajo Imperio</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:#de2d26;margin-right:8px;vertical-align:middle;"></span>General (Roman)</div>
</div>
<div style="margin-top:8px; color:#656d7e; font-size:12px; line-height:1.3;">
Nota: coordenadas “difuminadas” → interpreta zonas, no puntos exactos.
</div>
</div>
</div>
{% endmacro %}
"""
macro = MacroElement()
macro._template = Template(legend_html)
m2.get_root().add_child(macro)
Resultado
GRUPOS = {
"República (Roman Republic)": {
"roman republic",
},
"Imperio temprano (Early Imperial)": {
"roman early imperial",
},
"Alto Imperio (High Imperial)": {
"roman high imperial",
"roman imperial",
"roman rep/imperial",
},
"Bajo Imperio (Late Imperial / Late Roman)": {
"roman late imperial",
"late roman",
"roman/late roman",
"late roman/early byzantine",
"roman/byzantine",
"roman imperial/byzantine",
},
"General (Roman)": {
"roman",
}
}
GRADIENTES = {
"República (Roman Republic)": {0.4: "#fdd49e", 0.7: "#fc8d59", 1.0: "#d7301f"},
"Imperio temprano (Early Imperial)": {0.4: "#c7e9b4", 0.7: "#7fcdbb", 1.0: "#2c7fb8"},
"Alto Imperio (High Imperial)": {0.4: "#d9f0a3", 0.7: "#addd8e", 1.0: "#31a354"},
"Bajo Imperio (Late Imperial / Late Roman)": {0.4: "#e0ecf4", 0.7: "#9ebcda", 1.0: "#8856a7"},
"General (Roman)": {0.4: "#fee0d2", 0.7: "#fc9272", 1.0: "#de2d26"},
}
# Mapa base
m2 = folium.Map(location=ROMA, zoom_start=6, tiles="CartoDB positron")
fg_routes.add_to(m2)
# Heatmap por grupo
for grupo, periodos in GRUPOS.items():
subset = naufragios[naufragios["period_norm"].isin(periodos)]
if subset.empty:
continue
heat_data = subset[["Latitude", "Longitude"]].values.tolist()
fg = folium.FeatureGroup(name=f"Naufragios: {grupo} ({len(heat_data)})")
HeatMap(
heat_data,
radius=12,
blur=18,
max_zoom=6,
gradient=GRADIENTES.get(grupo, None)
).add_to(fg)
fg.add_to(m2)
folium.LayerControl(collapsed=True).add_to(m2)
# Leyenda (fixed box)
from branca.element import Template, MacroElement
legend_html = """
{% macro html(this, kwargs) %}
<div style="
position: fixed;
bottom: 18px;
left: 18px;
z-index: 9999;
pointer-events: none;
">
<div style="
pointer-events: auto;
background: rgba(255,255,255,0.96);
border: 1px solid rgba(0,0,0,.18);
border-radius: 10px;
padding: 10px 12px;
box-shadow: 0 10px 24px rgba(15,23,42,.18);
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
font-size: 13px;
line-height: 1.25;
max-width: 240px;
">
<div style="font-weight:800; margin:0 0 8px;">Leyenda (naufragios)</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<div><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:#d7301f;margin-right:8px;vertical-align:middle;"></span>República</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:#2c7fb8;margin-right:8px;vertical-align:middle;"></span>Imperio temprano</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:#31a354;margin-right:8px;vertical-align:middle;"></span>Alto Imperio</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:#8856a7;margin-right:8px;vertical-align:middle;"></span>Bajo Imperio</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:#de2d26;margin-right:8px;vertical-align:middle;"></span>General (Roman)</div>
</div>
<div style="margin-top:8px; color:#656d7e; font-size:12px; line-height:1.3;">
Nota: coordenadas “difuminadas” → interpreta zonas, no puntos exactos.
</div>
</div>
</div>
{% endmacro %}
"""
macro = MacroElement()
macro._template = Template(legend_html)
m2.get_root().add_child(macro)
# Guardamos el mapa mejorado
out2 = "tecnicas_visualización/mapa_rutas_marinas_naufragios_mejorado.html"
m2.save(out2)
print("Guardado:", out2)
Versión mejorada (por periodos + leyenda)
Conclusión
Al superponer las Sea Lanes de Itiner-e con los heatmaps de naufragios (OxREP) se ve un patrón muy claro: la mayor parte de la densidad aparece pegada a las costas y alrededor de los grandes corredores del Mediterráneo. Esto no lo interpretamos como “puntos exactos” (las coordenadas están difuminadas), sino como zonas de concentración coherentes con navegación, puertos y riesgos.
Que los naufragios se acumulen en orillas tiene varias explicaciones razonables (y compatibles entre sí):
- Navegación de cabotaje: durante buena parte de la Antigüedad lo habitual era navegar “a vista de costa” (más seguro y más fácil de orientar), así que el tráfico real se concentraba cerca de tierra.
- Puertos, fondeaderos y escalas: el comercio no era una línea directa A→B: se hacía por etapas. Eso multiplica el tiempo que los barcos pasan cerca de puertos y fondeaderos (y donde más maniobras hay, más accidentes ocurren).
- Peligros costeros: arrecifes, bajos, roquedos, corrientes locales, entradas a bahías, estrechos y cambios bruscos de viento. Cerca de costa tienes más “obstáculos” y menos margen de reacción que en mar abierto.
- Sesgo de detección/registro: es mucho más fácil localizar, bucear y estudiar restos en zonas accesibles (cerca de costa y a menor profundidad) que en mar abierto. El dataset refleja también dónde se ha investigado más.
- Efecto de la anonimización: al difuminar coordenadas por protección, muchos registros tienden a “caer” dentro de celdas/zonas amplias que a menudo siguen siendo litorales, reforzando visualmente el patrón costero.
En resumen: el mapa sugiere una lectura consistente con historia y con datos reales de observación. Las rutas marinas del mundo romano encajan con un Mediterráneo “costero”, estructurado por escalas, puertos y pasos obligados; y los naufragios aparecen donde esperaríamos que hubiera más tráfico, más maniobras y también más posibilidades de documentarlos.
Descarga el notebook
Si quieres replicarlo exactamente igual y jugar con él, aquí tienes el notebook completo listo para ejecutar.