Interactive Historical Map with WikidataΒΆ
This notebook creates an interactive map that queries Wikidata's SPARQL endpoint to display historical events and entities based on:
- Location: Latitude/Longitude with a configurable radius
- Time: A center date with +/- duration range
Use the interactive widgets to explore historical data from around the world!
InΒ [1]:
%pip install -q folium ipywidgets pandas
Note: you may need to restart the kernel to use updated packages.
InΒ [2]:
# Install required packages if not already installed
import subprocess
import sys
packages = ['folium', 'ipywidgets', 'requests', 'pandas']
for package in packages:
try:
__import__(package)
except ImportError:
subprocess.check_call([sys.executable, '-m', 'pip', 'install', package, '-q'])
InΒ [3]:
import folium
from folium.plugins import MarkerCluster
import ipywidgets as widgets
from IPython.display import display, HTML, clear_output
import requests
import pandas as pd
from datetime import datetime, timedelta
import json
import html
# Wikidata SPARQL endpoint
WIKIDATA_ENDPOINT = "https://query.wikidata.org/sparql"
InΒ [4]:
def query_wikidata(lat: float, lon: float, radius_km: float,
start_year: int, end_year: int, limit: int = 200) -> pd.DataFrame:
"""
Query Wikidata for historical items near a location within a time range.
Parameters:
- lat, lon: Center coordinates
- radius_km: Search radius in kilometers
- start_year, end_year: Time range for historical events
- limit: Maximum number of results
Returns: DataFrame with results
"""
# SPARQL query to find items with coordinates and dates within range
query = f"""
SELECT DISTINCT ?item ?itemLabel ?itemDescription ?lat ?lon ?date ?dateLabel ?image ?sitelink WHERE {{
# Items with geographic coordinates near the specified location
SERVICE wikibase:around {{
?item wdt:P625 ?location .
bd:serviceParam wikibase:center "Point({lon} {lat})"^^geo:wktLiteral .
bd:serviceParam wikibase:radius "{radius_km}" .
}}
# Get coordinates
?item wdt:P625 ?coords .
BIND(geof:latitude(?coords) AS ?lat)
BIND(geof:longitude(?coords) AS ?lon)
# Get dates - try multiple date properties
OPTIONAL {{ ?item wdt:P571 ?inception . }} # inception/creation date
OPTIONAL {{ ?item wdt:P580 ?startTime . }} # start time
OPTIONAL {{ ?item wdt:P585 ?pointInTime . }} # point in time
OPTIONAL {{ ?item wdt:P577 ?pubDate . }} # publication date
OPTIONAL {{ ?item wdt:P1619 ?openDate . }} # date of official opening
OPTIONAL {{ ?item wdt:P569 ?birthDate . }} # date of birth (for people)
OPTIONAL {{ ?item wdt:P570 ?deathDate . }} # date of death
# Use the first available date
BIND(COALESCE(?inception, ?startTime, ?pointInTime, ?pubDate, ?openDate, ?birthDate, ?deathDate) AS ?date)
# Filter by date range
FILTER(BOUND(?date))
FILTER(YEAR(?date) >= {start_year} && YEAR(?date) <= {end_year})
# Optional: get image
OPTIONAL {{ ?item wdt:P18 ?image . }}
# Optional: get Wikipedia sitelink
OPTIONAL {{
?sitelink schema:about ?item ;
schema:isPartOf <https://en.wikipedia.org/> .
}}
SERVICE wikibase:label {{ bd:serviceParam wikibase:language "[AUTO_LANGUAGE],en". }}
}}
ORDER BY ?date
LIMIT {limit}
"""
headers = {
'Accept': 'application/sparql-results+json',
'User-Agent': 'WikidataHistoricalMapNotebook/1.0'
}
try:
response = requests.get(
WIKIDATA_ENDPOINT,
params={'query': query},
headers=headers,
timeout=60
)
response.raise_for_status()
data = response.json()
# Parse results into DataFrame
results = []
for item in data['results']['bindings']:
results.append({
'item_id': item.get('item', {}).get('value', '').split('/')[-1],
'label': item.get('itemLabel', {}).get('value', 'Unknown'),
'description': item.get('itemDescription', {}).get('value', ''),
'lat': float(item.get('lat', {}).get('value', 0)),
'lon': float(item.get('lon', {}).get('value', 0)),
'date': item.get('date', {}).get('value', '')[:10], # Get just the date part
'date_label': item.get('dateLabel', {}).get('value', ''),
'image': item.get('image', {}).get('value', ''),
'wikipedia': item.get('sitelink', {}).get('value', ''),
'wikidata_url': item.get('item', {}).get('value', '')
})
df = pd.DataFrame(results)
if not df.empty:
df['year'] = pd.to_datetime(df['date'], errors='coerce').dt.year
return df
except requests.exceptions.RequestException as e:
print(f"Error querying Wikidata: {e}")
return pd.DataFrame()
InΒ [5]:
def create_popup_html(row: pd.Series) -> str:
"""Create HTML popup content for a map marker."""
# Escape HTML in text fields
label = html.escape(str(row['label']))
description = html.escape(str(row['description'])) if row['description'] else ''
date_str = row['date'] if row['date'] else 'Unknown date'
popup_html = f"""
<div style="min-width: 200px; max-width: 300px;">
<h4 style="margin: 0 0 8px 0; color: #1a73e8;">{label}</h4>
<p style="margin: 4px 0; font-size: 12px; color: #666;">
<strong>π
Date:</strong> {date_str}
</p>
"""
if description:
popup_html += f"""
<p style="margin: 4px 0; font-size: 12px;">{description}</p>
"""
if row['image']:
popup_html += f"""
<img src="{row['image']}" style="max-width: 100%; max-height: 150px; margin: 8px 0;"
onerror="this.style.display='none'">
"""
# Links
popup_html += '<div style="margin-top: 8px; font-size: 11px;">'
if row['wikidata_url']:
popup_html += f'<a href="{row["wikidata_url"]}" target="_blank">Wikidata</a>'
if row['wikipedia']:
popup_html += f' | <a href="{row["wikipedia"]}" target="_blank">Wikipedia</a>'
popup_html += '</div></div>'
return popup_html
def get_marker_color(year: int, start_year: int, end_year: int) -> str:
"""Get marker color based on year within the range (older = red, newer = blue)."""
if pd.isna(year):
return 'gray'
year_range = end_year - start_year
if year_range == 0:
return 'blue'
# Normalize year to 0-1 range
normalized = (year - start_year) / year_range
# Color gradient from red (old) to blue (new)
colors = ['darkred', 'red', 'orange', 'beige', 'green', 'darkgreen', 'blue', 'darkblue', 'purple']
index = int(normalized * (len(colors) - 1))
return colors[min(index, len(colors) - 1)]
def create_map(df: pd.DataFrame, center_lat: float, center_lon: float,
radius_km: float, start_year: int, end_year: int) -> folium.Map:
"""Create an interactive Folium map with markers for each result."""
# Create base map
m = folium.Map(
location=[center_lat, center_lon],
zoom_start=10,
tiles='OpenStreetMap'
)
# Add search radius circle
folium.Circle(
location=[center_lat, center_lon],
radius=radius_km * 1000, # Convert to meters
color='blue',
fill=True,
fillOpacity=0.1,
popup=f'Search radius: {radius_km} km'
).add_to(m)
# Add center marker
folium.Marker(
location=[center_lat, center_lon],
icon=folium.Icon(color='black', icon='crosshairs', prefix='fa'),
popup='Search center'
).add_to(m)
if df.empty:
return m
# Add marker cluster for better performance with many markers
marker_cluster = MarkerCluster(name='Historical Items').add_to(m)
# Add markers for each result
for _, row in df.iterrows():
color = get_marker_color(row.get('year'), start_year, end_year)
folium.Marker(
location=[row['lat'], row['lon']],
popup=folium.Popup(create_popup_html(row), max_width=350),
tooltip=f"{row['label']} ({row['date'][:4] if row['date'] else '?'})",
icon=folium.Icon(color=color, icon='info-sign')
).add_to(marker_cluster)
# Add layer control
folium.LayerControl().add_to(m)
return m
InΒ [6]:
def create_summary(df: pd.DataFrame, start_year: int, end_year: int) -> str:
"""Create a summary of the query results."""
if df.empty:
return "No results found for the specified criteria."
summary = f"""
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; margin: 10px 0;">
<h3 style="margin-top: 0;">π Query Results Summary</h3>
<ul style="margin: 0; padding-left: 20px;">
<li><strong>Total items found:</strong> {len(df)}</li>
<li><strong>Time range:</strong> {start_year} - {end_year}</li>
"""
if 'year' in df.columns and df['year'].notna().any():
year_counts = df['year'].value_counts().sort_index()
earliest = df['year'].min()
latest = df['year'].max()
summary += f"""
<li><strong>Earliest item:</strong> {int(earliest) if pd.notna(earliest) else 'N/A'}</li>
<li><strong>Latest item:</strong> {int(latest) if pd.notna(latest) else 'N/A'}</li>
"""
summary += """
</ul>
<h4 style="margin-bottom: 5px;">Sample Items:</h4>
<ul style="margin: 0; padding-left: 20px;">
"""
# Show first 5 items as examples
for _, row in df.head(5).iterrows():
label = html.escape(str(row['label']))
date = row['date'][:4] if row['date'] else '?'
desc = html.escape(str(row['description'])[:100]) + '...' if row['description'] and len(str(row['description'])) > 100 else html.escape(str(row['description'])) if row['description'] else ''
summary += f'<li><strong>{label}</strong> ({date}): {desc}</li>'
if len(df) > 5:
summary += f'<li><em>... and {len(df) - 5} more items</em></li>'
summary += """
</ul>
</div>
"""
return summary
def create_timeline_chart(df: pd.DataFrame) -> str:
"""Create a simple text-based timeline distribution."""
if df.empty or 'year' not in df.columns:
return ""
year_counts = df['year'].dropna().astype(int).value_counts().sort_index()
if year_counts.empty:
return ""
max_count = year_counts.max()
timeline_html = """
<div style="background: #fff; padding: 15px; border-radius: 8px; margin: 10px 0; border: 1px solid #ddd;">
<h4 style="margin-top: 0;">π
Timeline Distribution</h4>
<div style="font-family: monospace; font-size: 11px;">
"""
# Group by decades for cleaner display if range is large
year_range = year_counts.index.max() - year_counts.index.min()
if year_range > 100:
# Group by decades
df_temp = df.copy()
df_temp['decade'] = (df_temp['year'] // 10 * 10).astype('Int64')
decade_counts = df_temp['decade'].dropna().value_counts().sort_index()
max_count = decade_counts.max()
for decade, count in decade_counts.items():
bar_length = int((count / max_count) * 30)
bar = 'β' * bar_length
timeline_html += f'<div>{int(decade)}s: {bar} ({count})</div>'
else:
for year, count in year_counts.items():
bar_length = int((count / max_count) * 30)
bar = 'β' * bar_length
timeline_html += f'<div>{int(year)}: {bar} ({count})</div>'
timeline_html += """
</div>
</div>
"""
return timeline_html
Interactive ControlsΒΆ
Use the widgets below to configure your search:
- Location: Enter latitude and longitude coordinates
- Radius: Search radius in kilometers
- Time Range: Use the timeline slider to select a year range
- Preset Locations: Quick access to famous historical sites
InΒ [7]:
# Preset locations for quick exploration
PRESET_LOCATIONS = {
'Rome, Italy': (41.9028, 12.4964),
'Athens, Greece': (37.9838, 23.7275),
'Cairo, Egypt (Pyramids)': (29.9792, 31.1342),
'Paris, France': (48.8566, 2.3522),
'London, UK': (51.5074, -0.1278),
'Beijing, China': (39.9042, 116.4074),
'Jerusalem': (31.7683, 35.2137),
'Mexico City (Tenochtitlan)': (19.4326, -99.1332),
'Machu Picchu, Peru': (-13.1631, -72.5450),
'Angkor Wat, Cambodia': (13.4125, 103.8670),
'Istanbul, Turkey': (41.0082, 28.9784),
'New York, USA': (40.7128, -74.0060),
'Tokyo, Japan': (35.6762, 139.6503),
'Delhi, India': (28.6139, 77.2090),
}
# Create widgets
style = {'description_width': '120px'}
layout = widgets.Layout(width='350px')
# Location widgets
lat_input = widgets.FloatText(
value=41.9028, description='Latitude:',
style=style, layout=layout, step=0.01
)
lon_input = widgets.FloatText(
value=12.4964, description='Longitude:',
style=style, layout=layout, step=0.01
)
radius_slider = widgets.FloatSlider(
value=25, min=1, max=100, step=1,
description='Radius (km):', style=style, layout=layout
)
# Preset location dropdown
preset_dropdown = widgets.Dropdown(
options=['Custom'] + list(PRESET_LOCATIONS.keys()),
value='Rome, Italy',
description='Preset Location:',
style=style, layout=layout
)
# Timeline widgets
current_year = datetime.now().year
year_range_slider = widgets.IntRangeSlider(
value=[1800, 2000],
min=-3000, max=current_year,
step=10,
description='Year Range:',
style=style,
layout=widgets.Layout(width='500px'),
continuous_update=False
)
# Center year with duration (alternative input method)
center_year = widgets.IntSlider(
value=1900, min=-3000, max=current_year,
description='Center Year:', style=style, layout=layout
)
duration = widgets.IntSlider(
value=100, min=10, max=1000, step=10,
description='+/- Duration:', style=style, layout=layout
)
# Link center year and duration to range slider
def update_range_from_center(*args):
start = max(-3000, center_year.value - duration.value)
end = min(current_year, center_year.value + duration.value)
year_range_slider.value = (start, end)
center_year.observe(update_range_from_center, 'value')
duration.observe(update_range_from_center, 'value')
# Search button
search_button = widgets.Button(
description='π Search Wikidata',
button_style='primary',
layout=widgets.Layout(width='200px', height='40px')
)
# Output areas
map_output = widgets.Output()
summary_output = widgets.Output()
status_output = widgets.Output()
# Update location from preset
def on_preset_change(change):
if change['new'] != 'Custom' and change['new'] in PRESET_LOCATIONS:
lat, lon = PRESET_LOCATIONS[change['new']]
lat_input.value = lat
lon_input.value = lon
preset_dropdown.observe(on_preset_change, 'value')
# Set initial preset
on_preset_change({'new': 'Rome, Italy'})
InΒ [8]:
def perform_search(button):
"""Execute the Wikidata search and display results."""
with status_output:
clear_output()
print("π Querying Wikidata... (this may take a moment)")
# Get parameters
lat = lat_input.value
lon = lon_input.value
radius = radius_slider.value
start_year, end_year = year_range_slider.value
# Query Wikidata
df = query_wikidata(lat, lon, radius, start_year, end_year)
# Update status
with status_output:
clear_output()
if df.empty:
print("β οΈ No results found. Try expanding the radius or time range.")
else:
print(f"β
Found {len(df)} historical items!")
# Create and display map
with map_output:
clear_output()
m = create_map(df, lat, lon, radius, start_year, end_year)
display(m)
# Display summary
with summary_output:
clear_output()
display(HTML(create_summary(df, start_year, end_year)))
display(HTML(create_timeline_chart(df)))
# Show data table
if not df.empty:
display(HTML("<h4>π Results Table</h4>"))
display_df = df[['label', 'date', 'description', 'wikidata_url']].copy()
display_df.columns = ['Name', 'Date', 'Description', 'Wikidata Link']
display(display_df.head(20).style.set_properties(**{
'text-align': 'left',
'white-space': 'normal',
'max-width': '300px'
}))
# Connect button to search function
search_button.on_click(perform_search)
InΒ [9]:
# Display the interactive interface
print("πΊοΈ Historical Map Explorer - Wikidata Edition")
print("=" * 50)
# Location controls
location_box = widgets.VBox([
widgets.HTML("<h4>π Location Settings</h4>"),
preset_dropdown,
lat_input,
lon_input,
radius_slider
])
# Time controls
time_box = widgets.VBox([
widgets.HTML("<h4>β° Time Range Settings</h4>"),
year_range_slider,
widgets.HTML("<p style='font-size: 11px; color: #666;'>Or use center year +/- duration:</p>"),
center_year,
duration
])
# Layout
controls = widgets.HBox([location_box, time_box])
display(controls)
display(widgets.HBox([search_button]))
display(status_output)
# Create tabs for map and summary
tab = widgets.Tab(children=[map_output, summary_output])
tab.set_title(0, 'πΊοΈ Map')
tab.set_title(1, 'π Summary & Data')
display(tab)
# Show initial instructions
with summary_output:
display(HTML("""
<div style="padding: 20px; background: #e3f2fd; border-radius: 8px;">
<h3>π Welcome to the Historical Map Explorer!</h3>
<p>This tool queries Wikidata to find historical items near a location within a time range.</p>
<h4>How to use:</h4>
<ol>
<li>Select a preset location or enter custom coordinates</li>
<li>Adjust the search radius (in kilometers)</li>
<li>Set the time range using the slider or center year +/- duration</li>
<li>Click <strong>Search Wikidata</strong> to explore!</li>
</ol>
<h4>Tips:</h4>
<ul>
<li>π¨ Marker colors indicate age: <span style="color: darkred;">β</span> older β <span style="color: blue;">β</span> newer</li>
<li>π Click markers to see details, images, and links</li>
<li>π Results include links to Wikidata and Wikipedia articles</li>
<li>β³ Negative years represent BCE dates (e.g., -500 = 500 BCE)</li>
</ul>
</div>
"""))
πΊοΈ Historical Map Explorer - Wikidata Edition ==================================================
HBox(children=(VBox(children=(HTML(value='<h4>π Location Settings</h4>'), Dropdown(description='Preset Locatioβ¦
HBox(children=(Button(button_style='primary', description='π Search Wikidata', layout=Layout(height='40px', wiβ¦
Output()
Tab(children=(Output(), Output()), selected_index=0, titles=('πΊοΈ Map', 'π Summary & Data'))
Quick DemoΒΆ
Run the cell below to automatically perform a search for historical items near Rome, Italy between 1 CE and 500 CE (early Roman Empire period).
InΒ [10]:
# Quick demo: Search for historical items near Rome (1 CE - 500 CE)
demo_lat, demo_lon = 41.9028, 12.4964 # Rome
demo_radius = 30 # km
demo_start, demo_end = 1, 500 # Early Roman Empire
print(f"π Searching near Rome ({demo_lat}, {demo_lon})")
print(f"π Radius: {demo_radius} km")
print(f"π
Time range: {demo_start} CE - {demo_end} CE")
print("-" * 40)
# Query Wikidata
demo_df = query_wikidata(demo_lat, demo_lon, demo_radius, demo_start, demo_end)
if not demo_df.empty:
print(f"β
Found {len(demo_df)} historical items!")
# Display map
demo_map = create_map(demo_df, demo_lat, demo_lon, demo_radius, demo_start, demo_end)
display(demo_map)
# Display summary
display(HTML(create_summary(demo_df, demo_start, demo_end)))
display(HTML(create_timeline_chart(demo_df)))
else:
print("β οΈ No results found. The Wikidata query service might be busy. Try again later.")
π Searching near Rome (41.9028, 12.4964) π Radius: 30 km π Time range: 1 CE - 500 CE ---------------------------------------- β Found 122 historical items!
/tmp/ipykernel_23884/704636597.py:94: UserWarning: Could not infer format, so each element will be parsed individually, falling back to `dateutil`. To ensure parsing is consistent and as-expected, please specify a format. df['year'] = pd.to_datetime(df['date'], errors='coerce').dt.year
Make this Notebook Trusted to load map: File -> Trust Notebook
π Query Results Summary
- Total items found: 122
- Time range: 1 - 500
Sample Items:
- Diocese of Rome (0001): Catholic diocese in Rome, Italy
- Underground basilica of Porta Maggiore (0001): ancient religious monument in Rome, Italy
- Underground basilica of Porta Maggiore (0001): ancient religious monument in Rome, Italy
- Pancrazi tomb (Via Latina) (0001):
- Porta Caelimontana (0010): building in Rome, Italy
- ... and 117 more items
InΒ [Β ]: