← Volver

Visualización de rutas romanas con GeoPandas y Folium

Rutas romanas (imagen ilustrativa)

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:

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:

Campos útiles para empezar

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)
Abrir el mapa

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)

Abrir el mapa

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)

Abrir el mapa

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:

Estas son las columnas que vemos en el Excel (tal cual aparecen):

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

Abrir el mapa

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:

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:

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:

# 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)

Abrir el mapa

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í):

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.

📓 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. Itiner-e — About
  2. Itiner-e — Documentation
  3. OxREP (Oxford Roman Economy Project) — Shipwrecks Database (Strauss, 2013)
  4. OpenStreetMap — Datos de carreteras (OSM)
  5. OpenStreetMap — Copyright & License (ODbL)
  6. OSMnx — Documentación (descarga/red vial OSM)
  7. GeoPandas — Documentación
  8. Folium — Documentación