Amsterdam ⇄ New York · six essentials · one very honest map
How far do you actually live from your next flat white? From a public toilet (Amsterdam: everywhere; New York: godspeed)? This notebook measures the walk from every ~66 m hexagon of two city cores to six urban essentials — ☕ coffee, 🚽 toilets, 🌳 parks, 🛒 groceries, 🍺 pubs, 🚉 transit — and turns the result into a custom fullscreen web application, defined right here as an anywidget ESM module and driven by the analysis below.
What this page demonstrates:
~22,000 building footprints from Overture Maps GeoParquet on S3 (DuckDB), extruded to real heights in 3D
~2,500 H3 hexagons scored by network walking distance — multi-source Dijkstra over the OpenStreetMap pedestrian graph, not as-the-crow-flies
Live GPU filtering — “apartment hunt” sliders drive a deck.gl
DataFilterExtensionwith no kernel and no serverA bespoke app shell (
city_explorer/) — city switcher, persona recolors, and a click-anywhere inspector that draws spider lines to your nearest essentials
Scroll to the card at the bottom and hit ⛶ — or open the app directly.
# All sources are open and keyless; loaders are cache-first (see city_explorer_data.py).
from city_explorer_data import (
CATEGORY_KEYS, CITIES, MINUTES_CAP,
load_buildings, load_amenities, compute_hex_stats, load_city_stats,
)
DATA = {}
for city in CITIES:
DATA[city] = {
"buildings": load_buildings(city),
"amenities": load_amenities(city),
"hexes": compute_hex_stats(city),
"stats": load_city_stats(city),
}
d = DATA[city]
print(f"{CITIES[city]['label']}: {len(d['buildings']):,} buildings · "
f"{len(d['hexes']):,} hexes · {len(d['amenities']):,} amenities")
Amsterdam: 11,024 buildings · 317 hexes · 866 amenities
New York: 11,569 buildings · 1,012 hexes · 2,046 amenities
How the walking minutes are computed¶
For each city core we build the OSM pedestrian network with
osmnx, snap every amenity to its nearest graph
node, and run one multi-source Dijkstra per category — so each hexagon gets the
distance to its closest café, toilet, park edge (parks contribute their whole
boundary, not just a centroid), supermarket, pub and station, along actual streets
and canals. Minutes assume a civilised 80 m/min stroll and are capped at 30
(“bring provisions”). networkx.voronoi_cells remembers which amenity is closest —
that’s what the app’s spider lines point at.
import numpy as np
# Category definitions: color anchors the whole UI (chips, dots, spider lines).
# ratings = playful [max_minutes, label] ladders used by the click-inspector.
CATEGORY_DEFS = [
{"key": "coffee", "label": "Coffee", "emoji": "☕", "color": "#e08a4c", "rgba": (224, 138, 76),
"slider": True, "max": 30, "default": 30,
"ratings": [[2, "dangerously close"], [5, "civilised"], [10, "acceptable"],
[20, "concerning"], [99, "bring a thermos"]]},
{"key": "toilet", "label": "Public toilet", "emoji": "🚽", "color": "#5ad1b3", "rgba": (90, 209, 179),
"slider": True, "max": 30, "default": 30,
"ratings": [[3, "royal comfort"], [6, "you'll make it"], [12, "plan ahead"],
[99, "hold everything"]]},
{"key": "park", "label": "Park", "emoji": "🌳", "color": "#6fbf5a", "rgba": (111, 191, 90),
"slider": True, "max": 30, "default": 30,
"ratings": [[4, "practically a garden"], [8, "a pleasant stroll"], [15, "a commitment"],
[99, "concrete jungle"]]},
{"key": "grocery", "label": "Groceries", "emoji": "🛒", "color": "#f0c541", "rgba": (240, 197, 65),
"slider": False, "max": 30, "default": 30,
"ratings": [[3, "pantry-adjacent"], [7, "perfectly fine"], [15, "stock up when you go"],
[99, "delivery country"]]},
{"key": "pub", "label": "Pub / bar", "emoji": "🍺", "color": "#c777e0", "rgba": (199, 119, 224),
"slider": False, "max": 30, "default": 30,
"ratings": [[3, "regulars' territory"], [7, "sociably close"], [15, "designated walker"],
[99, "dry county"]]},
{"key": "transit", "label": "Transit", "emoji": "🚉", "color": "#5b9bf5", "rgba": (91, 155, 245),
"slider": True, "max": 30, "default": 30,
"ratings": [[3, "commuter heaven"], [7, "easy hop"], [15, "leave early"],
[99, "car country"]]},
]
assert [c["key"] for c in CATEGORY_DEFS] == CATEGORY_KEYS
SLIDER_KEYS = [c["key"] for c in CATEGORY_DEFS if c["slider"]]
PERSONAS = [
{"key": "coffee", "label": "The Coffee Achiever", "emoji": "☕",
"blurb": "Life is measured in flat whites. Everything else is logistics.",
"weights": {"coffee": 3.0, "grocery": 0.5, "pub": 0.5, "transit": 1.0}},
{"key": "parent", "label": "The Park Parent", "emoji": "🛝",
"blurb": "Needs grass, groceries, and an emergency toilet. In that order.",
"weights": {"park": 3.0, "grocery": 1.5, "toilet": 1.5, "transit": 1.0}},
{"key": "owl", "label": "The Night Owl", "emoji": "🦉",
"blurb": "The evening starts at nine. The last train is a rumour.",
"weights": {"pub": 3.0, "transit": 1.5, "coffee": 1.0}},
{"key": "zen", "label": "The Zen Generalist", "emoji": "🧘",
"blurb": "Wants a little of everything, walkable, no drama.",
"weights": {k: 1.0 for k in CATEGORY_KEYS}},
]
# Green → amber → red gradient (matches the app's legend CSS).
GRADIENT = [(0.0, (44, 122, 91)), (0.55, (255, 209, 102)), (1.0, (239, 99, 81))]
def grad(t):
t = float(min(max(t, 0.0), 1.0))
for (t0, c0), (t1, c1) in zip(GRADIENT, GRADIENT[1:]):
if t <= t1:
f = 0.0 if t1 == t0 else (t - t0) / (t1 - t0)
return tuple(round(a + (b - a) * f) for a, b in zip(c0, c1))
return GRADIENT[-1][1]
def score_rgba(hexes, weights, alpha=150, worst=18.0):
"""Weighted mean of clamped walking minutes -> RGBA per hex."""
total = np.zeros(len(hexes), dtype="float64")
wsum = 0.0
for key, w in weights.items():
total += np.minimum(hexes[f"{key}_min"].to_numpy(), MINUTES_CAP) * w
wsum += w
score = (total / wsum) / worst
return np.array([[*grad(t), alpha] for t in score], dtype="uint8")
for city in DATA:
hexes = DATA[city]["hexes"]
DATA[city]["hunt_rgba"] = score_rgba(hexes, PERSONAS[-1]["weights"]) # balanced
DATA[city]["persona_rgba"] = {
p["key"]: score_rgba(hexes, p["weights"]) for p in PERSONAS
}
print("palettes ready:", {c: len(DATA[c]["hunt_rgba"]) for c in DATA})
palettes ready: {'ams': 317, 'nyc': 1012}
The map stack¶
One shared lonboard map, fourteen layers, all client-side deck.gl:
Hex carpets —
H3HexagonLayers (just an H3 index + colors per cell — the cheapest geometry there is). The hunt layer carries a value-basedDataFilterExtension(filter_size=4): the app’s sliders rewritefilter_rangeand the GPU does the rest. One extra pre-colored layer per persona is toggled byvisible— the proven static-export pattern.Buildings — one
PolygonLayerper city,extruded=Truewith real Overture heights (deck’s H3 layer defaults to extruded 1000 m towers, so the hex layers sayextruded=Falseout loud).Amenity dots — one
ScatterplotLayerper city, colored by category.
from lonboard import Map, PolygonLayer, H3HexagonLayer, ScatterplotLayer
from lonboard.layer_extension import DataFilterExtension
from lonboard.basemap import CartoBasemap, MaplibreBasemap
from lonboard.controls import NavigationControl
CITY_ORDER = ["ams", "nyc"]
CAMERAS = {
"ams": dict(longitude=4.894, latitude=52.3715, zoom=14.1, pitch=50, bearing=-17),
"nyc": dict(longitude=-73.997, latitude=40.7185, zoom=13.9, pitch=50, bearing=28),
}
NEIGHBORHOODS = {
"ams": [
{"label": "Dam Square", "longitude": 4.8936, "latitude": 52.3731, "zoom": 15.6, "pitch": 55, "bearing": -20},
{"label": "Jordaan", "longitude": 4.8838, "latitude": 52.3745, "zoom": 15.4, "pitch": 55, "bearing": 30},
{"label": "Nieuwmarkt", "longitude": 4.9003, "latitude": 52.3723, "zoom": 15.7, "pitch": 55, "bearing": 0},
{"label": "Canal Ring South", "longitude": 4.8890, "latitude": 52.3648, "zoom": 15.3, "pitch": 55, "bearing": -40},
],
"nyc": [
{"label": "Washington Square", "longitude": -73.9973, "latitude": 40.7308, "zoom": 15.6, "pitch": 55, "bearing": 20},
{"label": "East Village", "longitude": -73.9838, "latitude": 40.7270, "zoom": 15.4, "pitch": 55, "bearing": -20},
{"label": "SoHo", "longitude": -74.0000, "latitude": 40.7235, "zoom": 15.6, "pitch": 55, "bearing": 10},
{"label": "Chinatown", "longitude": -73.9970, "latitude": 40.7150, "zoom": 15.5, "pitch": 55, "bearing": 45},
],
}
BUILDING_RGBA = {"ams": [172, 148, 126, 215], "nyc": [148, 158, 176, 215]}
filter_ext = DataFilterExtension(filter_size=len(SLIDER_KEYS))
building_layers, hunt_layers, amenity_layers = [], [], []
persona_layers, persona_layer_keys = [], []
for city in CITY_ORDER:
d = DATA[city]
first = city == CITY_ORDER[0]
b = d["buildings"]
building_layers.append(PolygonLayer.from_geopandas(
b[["geometry"]],
get_fill_color=BUILDING_RGBA[city],
get_elevation=b["height"].to_numpy(),
extruded=True, stroked=False,
visible=first,
))
hexes = d["hexes"]
filter_vals = np.column_stack(
[np.minimum(hexes[f"{k}_min"].to_numpy(), MINUTES_CAP) for k in SLIDER_KEYS]
).astype("float32")
hunt_layers.append(H3HexagonLayer.from_pandas(
hexes[["h3"]].copy(),
get_hexagon=hexes["h3"],
extensions=[filter_ext],
get_filter_value=filter_vals,
filter_range=[[0.0, 30.0]] * len(SLIDER_KEYS),
get_fill_color=d["hunt_rgba"],
extruded=False, stroked=False,
visible=first,
))
for p in PERSONAS:
persona_layers.append(H3HexagonLayer.from_pandas(
hexes[["h3"]].copy(),
get_hexagon=hexes["h3"],
get_fill_color=d["persona_rgba"][p["key"]],
extruded=False, stroked=False,
visible=False,
))
persona_layer_keys.append(f'{p["key"]}:{city}')
a = d["amenities"]
cat_rgba = {c["key"]: [*c["rgba"], 235] for c in CATEGORY_DEFS}
amenity_layers.append(ScatterplotLayer.from_geopandas(
a[["geometry"]],
get_fill_color=np.array([cat_rgba[c] for c in a["category"]], dtype="uint8"),
radius_min_pixels=2.5, radius_max_pixels=8,
stroked=True, get_line_color=[15, 18, 22, 160], line_width_min_pixels=0.5,
visible=False,
))
# Draw order: hex carpets under, buildings above, amenity dots on top.
city_map = Map(
[*hunt_layers, *persona_layers, *building_layers, *amenity_layers],
basemap=MaplibreBasemap(style=CartoBasemap.DarkMatterNoLabels),
view_state=CAMERAS["ams"], height="100%", controls=[NavigationControl()],
)
print(f"map ready: {len(city_map.layers)} layers")
map ready: 14 layers
A custom app in the fullscreen overlay¶
CityExplorer (in notebooks/city_explorer/) is a bespoke anywidget whose ESM
module is a small web application: a city switcher, apartment-hunt sliders,
persona cards, and a click-anywhere inspector that unprojects your click with
its own web-mercator math (the camera’s view_state trait is readable in the
static export), finds the containing hexagon, and draws SVG spider lines to the
six nearest essentials. It embeds the lonboard map through the same renderChild
mechanism the built-in layout containers use, and drives the layers directly from
JavaScript — GPU filter ranges, per-city visibility, 3D extrusion, camera fly-tos
— so everything keeps working in the kernel-free static export of this page.
manywidgets.Fullscreen supplies the takeover surface: inline you get a compact
teaser card; the ⛶ button (or the ?fullscreen=city-explorer deep link) opens
the full app.
from manywidgets import Fullscreen
from city_explorer import CityExplorer
CITY_META = {
"ams": {"label": "Amsterdam", "emoji": "🇳🇱",
"tagline": "the five-minute city"},
"nyc": {"label": "New York", "emoji": "🗽",
"tagline": "the city that never sits down"},
}
def fmt_pct(x):
return f"{x:.0f}%"
cities_trait = []
for city in CITY_ORDER:
d, s = DATA[city], DATA[city]["stats"]
other = [c for c in CITY_ORDER if c != city][0]
so = DATA[other]["stats"]
versus = []
for cat in CATEGORY_DEFS:
k = cat["key"]
mine, theirs = s["pct_within_5"][k], so["pct_within_5"][k]
versus.append({
"label": f'{cat["emoji"]} {cat["label"]} ≤ 5 min',
"value": f'{fmt_pct(mine)} vs {fmt_pct(theirs)}',
})
cities_trait.append({
"key": city,
"label": CITY_META[city]["label"],
"emoji": CITY_META[city]["emoji"],
"tagline": CITY_META[city]["tagline"],
"camera": CAMERAS[city],
"neighborhoods": NEIGHBORHOODS[city],
"stats": {
"tiles": [
{"label": "buildings in 3D", "value": f"{len(d['buildings']):,}"},
{"label": "hexes surveyed", "value": f"{len(d['hexes']):,}"},
{"label": "median walk to coffee", "value": f"{s['median_min']['coffee']:.0f} min"},
{"label": "toilet within 5 min", "value": fmt_pct(s["pct_within_5"]["toilet"])},
],
"versus": versus,
},
})
hex_index = {}
amenity_points = {}
for city in CITY_ORDER:
hexes = DATA[city]["hexes"]
hex_index[city] = {
"lon": [round(v, 6) for v in hexes["lon"]],
"lat": [round(v, 6) for v in hexes["lat"]],
"mins": [[round(float(r[f"{k}_min"]), 1) for k in CATEGORY_KEYS]
for _, r in hexes.iterrows()],
"idxs": [[int(r[f"{k}_idx"]) for k in CATEGORY_KEYS]
for _, r in hexes.iterrows()],
}
a = DATA[city]["amenities"]
pts = {}
for cat in CATEGORY_DEFS:
sub = a[a["category"] == cat["key"]].reset_index(drop=True)
pts[cat["key"]] = [
[round(float(lo), 6), round(float(la), 6),
(str(nm)[:40] if nm is not None and str(nm) != "nan" else None)]
for lo, la, nm in zip(sub["lon"], sub["lat"], sub["name"])
]
amenity_points[city] = pts
ams_s, nyc_s = DATA["ams"]["stats"], DATA["nyc"]["stats"]
meta = {
"kicker": "manywidgets · two cities, six essentials",
"title": "The Good Life Index",
"subtitle": ("Coffee, parks, pubs, trains — and yes, public toilets. Network walking "
"minutes from every 66 m hexagon of central Amsterdam and Lower Manhattan. "
"Computed in a notebook; no backend."),
"attribution": "© OpenStreetMap contributors (ODbL) · © Overture Maps · basemap © CARTO",
"credits": [
{"label": "OpenStreetMap — amenities & walking network", "url": "https://www.openstreetmap.org/copyright"},
{"label": "Overture Maps buildings", "url": "https://docs.overturemaps.org/"},
{"label": "osmnx + networkx (multi-source Dijkstra)", "url": "https://osmnx.readthedocs.io/"},
{"label": "H3 hexagonal grid", "url": "https://h3geo.org/"},
],
"note": "Walking speed 80 m/min; distances along the OSM pedestrian network, capped at 30 min.",
"license": "Data © OpenStreetMap contributors (ODbL) & Overture Maps Foundation.",
"verdicts": [
[5, "Move here immediately. Seriously, what are you waiting for?"],
[8, "The good life is in walking distance. All of it."],
[12, "Very livable. Invest in comfortable shoes."],
[18, "Charming, if you enjoy long contemplative walks."],
[99, "For hermits, poets and people with bicycles."],
],
"teaser_tiles": [
{"label": "cities, head to head", "value": "2"},
{"label": "hexes walked by Dijkstra", "value": f"{len(hex_index['ams']['lon']) + len(hex_index['nyc']['lon']):,}"},
{"label": f"AMS toilets ≤ 5 min · NYC {fmt_pct(nyc_s['pct_within_5']['toilet'])}",
"value": fmt_pct(ams_s["pct_within_5"]["toilet"])},
],
}
categories_trait = [
{k: c[k] for k in ("key", "label", "emoji", "color", "slider", "max", "default", "ratings")}
for c in CATEGORY_DEFS
]
personas_trait = [{k: p[k] for k in ("key", "label", "emoji", "blurb")} for p in PERSONAS]
app = CityExplorer(
mode="app", meta=meta, cities=cities_trait, categories=categories_trait,
personas=personas_trait, hex_index=hex_index, amenity_points=amenity_points,
persona_layer_keys=persona_layer_keys, city="ams", persona="",
map=city_map, building_layers=building_layers, hunt_layers=hunt_layers,
persona_layers=persona_layers, amenity_layers=amenity_layers,
)
teaser = CityExplorer(mode="teaser", meta=meta)
fs = Fullscreen(
teaser, fullscreen=app, widget_id="city-explorer",
style={
"--mw-panel-padding-x": "0px",
"--mw-panel-padding-y": "0px",
"--mw-color-overlay": "rgba(5, 8, 12, 0.94)",
"--mw-color-surface": "#0f1216",
"--mw-control-max-width": "100%",
},
)
fs
How this works (and why it’s reproducible)¶
No kernel needed to view this. The page is a static export: every widget’s state (including the Arrow tables behind the lonboard layers) was captured when the notebook executed, and all interactivity — sliders, personas, the inspector, the city hop — is client-side JavaScript talking to the serialized models.
The sliders are a GPU feature. Each hexagon carries its four walking times as a
DataFilterExtensionvalue; dragging a slider rewritesfilter_rangeon the layer model and deck.gl re-filters in a single frame. No data moves.The inspector is just math. The camera’s
view_stateis readable in the export, so the app unprojects your click with ~15 lines of web-mercator, finds the nearest hex centroid, and reads that hexagon’s precomputed answers.Re-run it yourself.
just setup && just execute && just preview— the loaders fetch Overture buildings via DuckDB and OSM amenities/network via osmnx (or reuse the local cache) and the same notebook produces the same app.Deep link:
?fullscreen=city-exploreropens the app directly.
Attribution: Amenities & pedestrian network © OpenStreetMap contributors (ODbL) · Building footprints © Overture Maps Foundation · Hex grid: Uber H3 · Routing: osmnx/networkx · Basemap © CARTO · Built with lonboard, anywidget, manywidgets & MyST.