---
title: "Roads of the Roman Empire"
subtitle: "The Itiner-e Digital Atlas — Routes, Stations & the Ancient Network"
author: "Ryan Lafferty"
date: last-modified
format:
html:
toc: true
code-fold: true
code-summary: "Show code"
embed-resources: true
jupyter: python3
execute:
cache: false
---
## All Roads Lead to Rome
At its peak, the Roman road network stretched over 250,000 miles: an engineered web of stone and gravel that stitched together an empire spanning from the rain-soaked hills of Britannia to the sun-scorched deserts of Mesopotamia. These were not mere footpaths. They were graded, drained, and surfaced with polygonal basalt blocks, designed to move legions at speed and hold together the most complex administrative state the ancient world had ever known. The *viae publicae*, the great trunk roads like the Via Appia, the Via Egnatia, and the Via Domitia, served as the arteries of empire, carrying soldiers, merchants, tax collectors, and couriers across landscapes that had never before seen a straight line.
The data in this analysis comes from the **Itiner-e** digital atlas of ancient Roman roads (Brughmans, de Soto, Pazout & Bjerregaard Vahlstrup, 2024), a collaborative project by Aarhus University that reconstructs the network from archaeological evidence, historical itineraries, and the Pleiades ancient places gazetteer. Licensed CC BY 4.0.
<!-- TODO: Expand with narrative about the engineering techniques (via munita),
the cursus publicus postal system, and how the network shaped medieval
and modern European geography. -->
---
## The Road Network
```{python}
import sys, platform
sys.path.append("..")
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
import json
from db_connection import query_to_geodataframe, query_to_dataframe
from map_builder import (
create_base_map, add_geodataframe_layer, add_point_markers,
finalize_map, build_photo_popup,
)
# Bounding box: West 5.74, South 36.71, East 29.04, North 48.47
BBOX = "ST_MakeEnvelope(5.743307, 36.706750, 29.040825, 48.474916, 4326)"
# Load road segments within bounding box (simplified for performance)
segments = query_to_geodataframe(f"""
SELECT segment_id, name, road_type, segment_certainty,
construction_period, itinerary, description,
length_m, lower_date, upper_date, source_url,
ST_Simplify(geometry, 0.005) AS geom
FROM roman_road_segments
WHERE ST_Intersects(geometry, {BBOX})
""", geom_col="geom", crs=4326)
# Load places within bounding box (major settlements, forts, bridges only)
places = query_to_geodataframe(f"""
SELECT pleiades_id, name, place_type, start_year, end_year, url,
geometry AS geom
FROM roman_road_places
WHERE ST_Intersects(geometry, {BBOX})
AND place_type IN ('major-settlement', 'fort', 'bridge')
""", geom_col="geom", crs=4326)
# Load user-curated POIs
with open("roman-roads-poi.json", "r", encoding="utf-8") as f:
poi_data = json.load(f)
print(f"{len(segments)} road segments loaded")
print(f"{len(places)} Pleiades places loaded")
print(f"{len(poi_data)} user POIs loaded")
```
### By Road Type
```{python}
type_counts = (
segments.groupby("road_type")
.size()
.reset_index(name="count")
.sort_values("count", ascending=False)
)
type_counts
```
### By Construction Period
```{python}
period_counts = (
segments.groupby("construction_period")
.size()
.reset_index(name="count")
.sort_values("count", ascending=False)
)
period_counts
```
<!-- Uncomment to add certainty breakdown:
By Segment Certainty — certainty_counts = segments.groupby("segment_certainty").size()... -->
---
## Stationes and Mansiones
```{python}
# Display places table
display_places = places[["name", "place_type", "start_year", "end_year"]].copy()
display_places.columns = ["Name", "Type", "From", "To"]
display_places.sort_values("Name").head(30)
```
<!-- TODO: Add narrative about the Roman road station system —
mutationes (horse-changing posts every 10-15 miles),
mansiones (overnight inns every 25-30 miles),
and how they formed the backbone of the cursus publicus. -->
---
## The Empire's Road Map
```{python}
import folium
# Base map centered on study area (positron for European geography)
m = create_base_map(center=[42.59, 17.39], zoom=5, tiles="positron")
# --- Road segments by type ---
road_colors = {
"Main Road": {"color": "#f97316", "weight": 3, "opacity": 0.9},
"Secondary Road": {"color": "#d97706", "weight": 2, "opacity": 0.8},
"Sea Lane": {"color": "#64748b", "weight": 2, "opacity": 0.6, "dashArray": "8 4"},
"River": {"color": "#0ea5e9", "weight": 2, "opacity": 0.7},
}
for road_type, style in road_colors.items():
subset = segments[segments["road_type"] == road_type]
if len(subset) > 0:
m = add_geodataframe_layer(
m, subset,
name=road_type,
tooltip_fields=["name", "road_type", "construction_period"],
style=style,
highlight={"weight": style.get("weight", 2) + 2, "opacity": 1},
)
# Remaining road types (if any not in the dict above)
known_types = set(road_colors.keys())
other = segments[~segments["road_type"].isin(known_types)]
if len(other) > 0:
m = add_geodataframe_layer(
m, other,
name="Other Roads",
tooltip_fields=["name", "road_type", "construction_period"],
style={"color": "#a855f7", "weight": 1.5, "opacity": 0.6},
)
# --- Places as GeoJSON layer (major settlements, forts, bridges) ---
m = add_geodataframe_layer(
m, places,
name="Pleiades Places",
tooltip_fields=["name", "place_type"],
style={"color": "#f43f5e", "weight": 0.5, "fillOpacity": 0.4, "radius": 3},
show=False,
)
# --- User-curated POIs with photo support ---
for poi in poi_data:
popup_html = build_photo_popup(
poi,
photo_url=poi.get("photo_url") or None,
photo_caption=poi.get("photo_caption") or None,
)
folium.Marker(
location=[poi["lat"], poi["lon"]],
popup=folium.Popup(popup_html, max_width=450),
tooltip=f"<b>{poi['name']}</b>",
icon=folium.Icon(color="red", icon="star", prefix="fa"),
).add_to(m)
m = finalize_map(m)
m
```
---
## Points of Interest
User-curated points of interest with photos and descriptions. Add entries to `roman-roads-poi.json` to populate this section.
```{python}
if poi_data:
poi_df = pd.DataFrame(poi_data)
display_poi = poi_df[["name", "category", "description"]].copy()
display_poi.columns = ["Name", "Category", "Description"]
display_poi.style.set_properties(
subset=["Description"], **{"min-width": "350px", "white-space": "normal"}
)
else:
print("No POIs added yet — edit roman-roads-poi.json to get started.")
```
<!-- TODO: As Ryan adds POIs with photos, this section can become a
photo gallery with narrative descriptions for each site. -->
---
## Construction Timeline
```{python}
import plotly.express as px
# Filter to segments with date information
dated = segments.dropna(subset=["lower_date"]).copy()
dated = dated[dated["lower_date"] != 0]
if len(dated) > 0:
# Aggregate by road_type and lower_date for a manageable chart
date_summary = (
dated.groupby(["road_type", "lower_date"])
.size()
.reset_index(name="segment_count")
)
fig = px.scatter(
date_summary,
x="lower_date",
y="road_type",
size="segment_count",
color="road_type",
title="Road Construction by Period",
labels={"lower_date": "Date (BCE/CE)", "road_type": "Road Type",
"segment_count": "Segments"},
color_discrete_map={
"Main Road": "#f97316",
"Secondary Road": "#d97706",
"Sea Lane": "#64748b",
"River": "#0ea5e9",
},
)
fig.update_layout(height=400, showlegend=True)
fig.show()
else:
print("No dated segments in the study area.")
```
<!-- TODO: Add narrative about the chronological development of the network —
earliest roads in Latium (4th c. BCE), expansion during the Republic,
and the great imperial building programs under Augustus and Trajan. -->
---
## Static Overview
```{python}
import contextily as cx
# Reproject to Web Mercator for contextily basemap tiles
segments_3857 = segments.to_crs(epsg=3857)
fig, ax = plt.subplots(1, 1, figsize=(14, 10))
color_map = {
"Main Road": ("#f97316", 0.8, 0.9),
"Secondary Road": ("#d97706", 0.4, 0.8),
"Sea Lane": ("#64748b", 0.4, 0.6),
"River": ("#0ea5e9", 0.4, 0.7),
}
for road_type, (color, lw, alpha) in color_map.items():
subset = segments_3857[segments_3857["road_type"] == road_type]
if len(subset) > 0:
subset.plot(ax=ax, color=color, linewidth=lw, label=road_type, alpha=alpha)
# Remaining types
other_3857 = segments_3857[~segments_3857["road_type"].isin(color_map.keys())]
if len(other_3857) > 0:
other_3857.plot(ax=ax, color="#a855f7", linewidth=0.3, label="Other", alpha=0.5)
# Add hillshade/terrain basemap (Esri — no API key needed)
cx.add_basemap(ax, source=cx.providers.Esri.WorldShadedRelief, zoom=5)
ax.set_title("Roman Road Network — Study Area", fontsize=14)
ax.set_axis_off()
ax.legend(loc="lower left", fontsize=8, framealpha=0.9)
plt.tight_layout()
plt.show()
```
---
## Data Source & Bibliography
### Itiner-e Digital Atlas
Brughmans, T., de Soto, P., Pazout, A. and Bjerregaard Vahlstrup, P. (2024). *Itiner-e: the digital atlas of ancient roads*. Aarhus University. Licensed CC BY 4.0.
Website: [https://www.itiner-e.org](https://www.itiner-e.org)
### Pleiades Gazetteer
Place data linked via the Pleiades ancient world gazetteer ([https://pleiades.stoa.org](https://pleiades.stoa.org)), a community-built geographic information system for ancient places.
<!-- TODO: Add specific route citations as the narrative expands:
- Via Appia sources
- Via Egnatia sources
- Via Domitia sources
- Archaeological survey references for specific regions -->
---
## Technical Details
### Environment
```{python}
print("Python:", sys.version.split()[0])
print("Platform:", platform.platform())
print("GeoPandas:", gpd.__version__)
print("Pandas:", pd.__version__)
```
### Data Summary
```{python}
print(f"Road segments (study area): {len(segments)}")
print(f"Pleiades places (study area): {len(places)}")
print(f"User POIs: {len(poi_data)}")
print(f"CRS: {segments.crs}")
print(f"\nBounding box: W 5.74, S 36.71, E 29.04, N 48.47")
```
### Feature Breakdown
```{python}
type_cert = (
segments.groupby(["road_type", "segment_certainty"])
.size()
.reset_index(name="count")
.sort_values("count", ascending=False)
)
type_cert
```
```{python}
place_type_counts = (
places.groupby("place_type")
.size()
.reset_index(name="count")
.sort_values("count", ascending=False)
)
place_type_counts
```
### Notes / Decision Log
- **Data source**: Itiner-e nightly bulk NDJSON export, loaded via `roman_roads_harvester.py` into PostGIS (`everglades_gis` database)
- **Tables**: `roman_road_segments` (LINESTRING, 4326), `roman_road_places` (POINT, 4326) — separate from Everglades tables
- **Bounding box**: Study area filtered to core Roman Empire (W 5.74, S 36.71, E 29.04, N 48.47)
- **CRS**: EPSG:4326 (WGS84) — native CRS of the Itiner-e data
- **Photo popups**: POI data stored in `roman-roads-poi.json`, supports external image URLs via `<img>` tags
- **Next steps**: Add POI photos, expand narrative sections, regional detail maps