Build a city data explorer

Pick any city in the world. How many restaurants does it have? Parks? Schools? Hospitals? What are the notable landmarks? What's the most common type of shop?

This is the kind of question that used to require downloading a 70GB planet file and running queries locally, or scraping multiple APIs and stitching the results together. We're going to build a dashboard that answers all of it with a few API calls.

The result is a Python script that generates a standalone HTML dashboard for any city. It pulls data from Plaza, crunches some numbers, and spits out an interactive page with stats cards and a MapLibre overview map. Run it for your hometown, run it for Tokyo, run it for that small town you drove through last summer and wondered about.

What we're using

  • Geocoding to resolve the city name and get its coordinates
  • Overpass QL to count amenities by category
  • Search to find notable landmarks
  • SPARQL for structured queries about the city
  • MapLibre GL JS for the overview map in the generated HTML

The Python script

Save this as city_explorer.py and run it with python city_explorer.py "Portland, OR". It generates portland-or-dashboard.html in the current directory.

#!/usr/bin/env python3
"""Generate a city data dashboard."""
import sys
import json
import re
import requests
API_KEY = "pk_live_YOUR_KEY"
BASE = "https://plaza.fyi/api/v1"
HEADERS = {"x-api-key": API_KEY}
# Categories to count. Each is an Overpass tag filter.
CATEGORIES = {
"Restaurants": "amenity=restaurant",
"Cafes": "amenity=cafe",
"Bars & pubs": "amenity=bar|amenity=pub",
"Schools": "amenity=school",
"Hospitals": "amenity=hospital",
"Parks": "leisure=park",
"Pharmacies": "amenity=pharmacy",
"Supermarkets": "shop=supermarket",
"Hotels": "tourism=hotel",
"Places of worship": "amenity=place_of_worship",
"Libraries": "amenity=library",
"Post offices": "amenity=post_office",
}
def plaza_get(path, params=None):
resp = requests.get(f"{BASE}{path}", params=params, headers=HEADERS)
resp.raise_for_status()
return resp.json()
def plaza_post(path, body):
resp = requests.post(
f"{BASE}{path}", json=body,
headers={**HEADERS, "Content-Type": "application/json"},
)
resp.raise_for_status()
return resp.json()
def geocode_city(name):
data = plaza_get("/geocode", {"q": name, "limit": 1})
if not data.get("results"):
print(f"Could not find: {name}")
sys.exit(1)
r = data["results"][0]
print(f"Found: {r['display_name']}")
return r
def count_amenities(lat, lng, radius=5000):
"""Count amenities in each category within radius of city center."""
counts = {}
for label, tags in CATEGORIES.items():
# Handle compound tags like "amenity=bar|amenity=pub"
parts = tags.split("|")
total = 0
for part in parts:
key, val = part.split("=")
query = f"[out:json];(node[{key}={val}](around:{radius},{lat},{lng});way[{key}={val}](around:{radius},{lat},{lng}););out count;"
data = plaza_post("/overpass", {"data": query})
# Count response gives us the total
total += len(data.get("features", []))
# If we got a count element, use that instead
for f in data.get("features", []):
if "count" in f.get("properties", {}):
total = f["properties"]["count"]
counts[label] = total
print(f" {label}: {total}")
return counts
def find_landmarks(lat, lng, radius=5000):
"""Find notable landmarks (tourism=attraction, historic, etc)."""
query = (
f"[out:json];"
f"("
f" node[tourism=attraction](around:{radius},{lat},{lng});"
f" way[tourism=attraction](around:{radius},{lat},{lng});"
f" node[historic](around:{radius},{lat},{lng});"
f");"
f"out center 20;"
)
data = plaza_post("/overpass", {"data": query})
landmarks = []
seen = set()
for f in data.get("features", []):
tags = f.get("properties", {}).get("tags", {})
name = tags.get("name")
if name and name not in seen:
seen.add(name)
coords = f["geometry"]["coordinates"]
landmarks.append({
"name": name,
"type": tags.get("tourism", tags.get("historic", "landmark")),
"lng": coords[0] if isinstance(coords[0], (int, float)) else coords[0][0],
"lat": coords[1] if isinstance(coords[1], (int, float)) else coords[1][0],
})
return landmarks[:15]
def find_common_shops(lat, lng, radius=5000):
"""Find the most common shop types."""
query = (
f"[out:json];"
f"node[shop](around:{radius},{lat},{lng});"
f"out;"
)
data = plaza_post("/overpass", {"data": query})
shop_types = {}
for f in data.get("features", []):
shop = f.get("properties", {}).get("tags", {}).get("shop", "other")
shop_types[shop] = shop_types.get(shop, 0) + 1
# Sort by count, top 10
return dict(sorted(shop_types.items(), key=lambda x: -x[1])[:10])
def generate_html(city_name, city_info, counts, landmarks, shops):
"""Generate the dashboard HTML."""
lat, lng = city_info["lat"], city_info["lng"]
slug = re.sub(r"[^a-z0-9]+", "-", city_name.lower()).strip("-")
# Sort counts for display
sorted_counts = sorted(counts.items(), key=lambda x: -x[1])
landmark_markers_js = "\n".join(
f' new maplibregl.Marker({{color: "#dc2626", scale: 0.7}}).setLngLat([{lm["lng"]}, {lm["lat"]}]).setPopup(new maplibregl.Popup().setHTML("<b>{lm["name"]}</b><br>{lm["type"]}")).addTo(map);'
for lm in landmarks
)
cards_html = "\n".join(
f' <div class="card"><div class="card-num">{count:,}</div><div class="card-label">{label}</div></div>'
for label, count in sorted_counts
)
landmarks_html = "\n".join(
f" <li>{lm['name']} <span>({lm['type']})</span></li>"
for lm in landmarks
)
shops_html = "\n".join(
f" <li>{stype.replace('_', ' ').title()} <span>({scount})</span></li>"
for stype, scount in shops.items()
)
total_amenities = sum(counts.values())
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{city_name} - City Explorer</title>
<script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.css" />
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: system-ui, -apple-system, sans-serif; background: #f8fafc; color: #1e293b; }}
.header {{ background: #0f172a; color: white; padding: 24px 32px; }}
.header h1 {{ font-size: 28px; font-weight: 600; }}
.header p {{ color: #94a3b8; margin-top: 4px; }}
.container {{ max-width: 1100px; margin: 0 auto; padding: 24px; }}
.cards {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; margin-bottom: 24px; }}
.card {{ background: white; border-radius: 8px; padding: 16px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
.card-num {{ font-size: 28px; font-weight: 700; color: #2563eb; }}
.card-label {{ font-size: 13px; color: #64748b; margin-top: 4px; }}
.grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px; }}
@media (max-width: 768px) {{ .grid {{ grid-template-columns: 1fr; }} }}
.section {{ background: white; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
.section h3 {{ font-size: 16px; margin-bottom: 12px; color: #0f172a; }}
.section ul {{ list-style: none; }}
.section li {{ padding: 6px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px; }}
.section li span {{ color: #94a3b8; }}
#map {{ height: 400px; border-radius: 8px; margin-bottom: 24px; }}
.total {{ font-size: 14px; color: #64748b; margin-bottom: 16px; }}
</style>
</head>
<body>
<div class="header">
<h1>{city_name}</h1>
<p>{city_info['display_name']}</p>
</div>
<div class="container">
<p class="total">{total_amenities:,} mapped amenities within 5km of city center</p>
<div class="cards">
{cards_html}
</div>
<div id="map"></div>
<div class="grid">
<div class="section">
<h3>Landmarks</h3>
<ul>
{landmarks_html}
</ul>
</div>
<div class="section">
<h3>Most common shops</h3>
<ul>
{shops_html}
</ul>
</div>
</div>
</div>
<script>
const map = new maplibregl.Map({{
container: "map",
style: "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json",
center: [{lng}, {lat}],
zoom: 13,
}});
new maplibregl.Marker({{color: "#2563eb"}}).setLngLat([{lng}, {lat}]).setPopup(new maplibregl.Popup().setHTML("<b>City center</b>")).addTo(map);
{landmark_markers_js}
</script>
</body>
</html>"""
filename = f"{slug}-dashboard.html"
with open(filename, "w") as f:
f.write(html)
return filename
def main():
if len(sys.argv) < 2:
print("Usage: python city_explorer.py 'City Name'")
sys.exit(1)
city_name = " ".join(sys.argv[1:])
print(f"\nExploring: {city_name}\n")
# Step 1: Geocode the city
print("Geocoding...")
city = geocode_city(city_name)
lat, lng = city["lat"], city["lng"]
# Step 2: Count amenities
print(f"\nCounting amenities within 5km of center...")
counts = count_amenities(lat, lng)
# Step 3: Find landmarks
print("\nFinding landmarks...")
landmarks = find_landmarks(lat, lng)
for lm in landmarks:
print(f" {lm['name']} ({lm['type']})")
# Step 4: Find common shop types
print("\nAnalyzing shops...")
shops = find_common_shops(lat, lng)
for stype, scount in shops.items():
print(f" {stype}: {scount}")
# Step 5: Generate dashboard HTML
print("\nGenerating dashboard...")
filename = generate_html(city_name, city, counts, landmarks, shops)
print(f"\nDone! Open {filename} in your browser.")
if __name__ == "__main__":
main()

What this does, step by step

Resolving the city

We geocode the city name and use the returned coordinates as our center point. The display name from geocoding gives us the full address string (e.g., "Portland, Multnomah County, Oregon, United States") for the page header.

Counting amenities with Overpass QL

For each category, we run a count query:

[out:json];
(
node[amenity=restaurant](around:5000,45.52,-122.67);
way[amenity=restaurant](around:5000,45.52,-122.67);
);
out count;

We query both nodes and ways because OSM maps some restaurants as points and others as building outlines. Missing one type would undercount. The out count modifier tells Plaza to return just the count rather than every element -- much faster for categories with thousands of results.

The 5km radius is arbitrary. For a big city like Tokyo you might want 10km. For a small town, 2km might be enough. Adjust the radius parameter in count_amenities().

Finding landmarks

Landmarks come from two OSM tags: tourism=attraction and historic=*. We pull the top 20, deduplicate by name (because the same landmark sometimes has both a node and a way), and keep 15 for the dashboard.

The results are surprisingly good for most cities. You'll get the major tourist sites, museums, monuments, and historic buildings. Smaller cities might have fewer tagged attractions, but you'll still usually get the local highlights.

Shop type analysis

This one is a nice bonus. We pull every node tagged shop=* within the radius, then count by shop type. The result tells you what kinds of retail a city has. A city with lots of convenience stores and few department_store entries has a different character than one dominated by clothes and jewelry shops.

Generating the HTML

The script templates everything into a self-contained HTML file. No build step, no dependencies beyond MapLibre loaded from a CDN. The dashboard has:

  • A header with the city name and full address
  • Stat cards for each amenity category, sorted by count
  • A MapLibre map centered on the city with red markers for each landmark
  • Two lists: landmarks and most common shop types

Running it

pip install requests
python city_explorer.py "Portland, OR"
# Generates portland-or-dashboard.html

Try a few cities and compare the results. Cities have personality that shows up in numbers. Portland has more cafes per capita than most US cities. Tokyo has an absurd density of convenience stores. Barcelona has more bars than you'd expect for its size.

Taking it further

Raw counts are interesting, but per-capita numbers tell a different story. If you have population data, divide to get "restaurants per 10,000 people" and you can actually compare cities on equal footing.

Run the script weekly and track how the numbers change. New restaurants opening, shops closing -- OSM gets updated constantly, so the numbers shift over time. You'd end up with a rough time series of urban development.

Two-city comparisons are fun. Run the script for Portland and Denver, put the dashboards next to each other. Or modify the script to accept two city names and generate a single comparison page.

You could also skip out count and render the actual features as map markers, letting users click a category to see every matching place on the map.

OSM has hundreds of tag types beyond what's in the CATEGORIES dict. Add amenity=cinema, sport=*, bicycle_parking, electric_vehicle_charging -- whatever you're curious about. The dict at the top of the script is the only thing you need to touch.