home Interactive Historical Map with Wikidata
edit lab down

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Β [Β ]: