Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

The Good Life Index

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 DataFilterExtension with no kernel and no server

  • A 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:

  1. Hex carpetsH3HexagonLayers (just an H3 index + colors per cell — the cheapest geometry there is). The hunt layer carries a value-based DataFilterExtension (filter_size=4): the app’s sliders rewrite filter_range and the GPU does the rest. One extra pre-colored layer per persona is toggled by visible — the proven static-export pattern.

  2. Buildings — one PolygonLayer per city, extruded=True with real Overture heights (deck’s H3 layer defaults to extruded 1000 m towers, so the hex layers say extruded=False out loud).

  3. Amenity dots — one ScatterplotLayer per 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 DataFilterExtension value; dragging a slider rewrites filter_range on the layer model and deck.gl re-filters in a single frame. No data moves.

  • The inspector is just math. The camera’s view_state is 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-explorer opens 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.