Three widgets on one page, all backed by the same dataset of 124K NYC buildings:
Custom dashboard — height-bucket toggles + live stats counters. Toggle a bucket and the matching map layer disappears; the counters retween.
Lonboard map — every building footprint as a real polygon, colored by height. Five
PolygonLayers (one per bucket) so the dashboard can flip each layer’svisibletrait independently.ChartGPU scatter — every building sorted by height. Each bucket is its own colored series so the bucket sizes and the long supertall tail are visible at a glance.
Data is from Overture Maps, fetched at prebuild time by data/nyc/fetch.py. The buildings span Manhattan + Long Island City + parts of Brooklyn — enough to surface the supertall office stock without crashing the page weight.
Loading and bucketing¶
Overture’s height (in metres) is populated for ~98% of buildings; num_floors is sparse (~5%). We prefer num_floors when present and otherwise infer floors as round(height / 3). Buildings with no derivable level count get dropped (a small ~2% slice). Each surviving building gets bucketed by levels into one of five bands, which become the five lonboard layers.
import sys, pathlib, itertools
ROOT = pathlib.Path("..").resolve() if pathlib.Path("..").exists() else pathlib.Path("../..").resolve()
sys.path.insert(0, str(ROOT))
import numpy as np
import pandas as pd
import geopandas as gpd
from IPython.display import display
from lonboard import Map, PolygonLayer
from widgets.chartgpu_widget import ChartGPUWidget
from widgets.nyc_dashboard import NYCDashboard
DATA = ROOT / "data" / "nyc"
gdf = gpd.read_parquet(DATA / "buildings.parquet")
# Derive floors: prefer num_floors, else height/3.0, else drop
levels = gdf['num_floors'].astype('float64')
missing = levels.isna()
levels[missing] = (gdf.loc[missing, 'height'] / 3.0).round()
gdf['levels'] = levels
gdf = gdf[gdf['levels'].notna() & (gdf['levels'] >= 1)].copy()
gdf['levels'] = gdf['levels'].astype('int32')
# Footprint area in square meters (project to web mercator for area)
gdf['footprint_m2'] = gdf.to_crs('EPSG:3857').geometry.area.round(1)
# Extract building name from Overture's nested names struct: {"primary": "...", ...}
def primary_name(n):
if isinstance(n, dict):
return n.get('primary')
return None
gdf['name'] = gdf['names'].map(primary_name).astype('string').fillna('')
# Building class (sparse but useful where present, e.g. "apartments", "school")
gdf['class'] = gdf['class'].astype('string').fillna('')
# Round height for nicer hover display
gdf['height_m'] = gdf['height'].round(1)
# Bucket by levels
def bucket_of(lv: int) -> str:
if lv <= 1: return 'b_1'
if lv <= 4: return 'b_2_4'
if lv <= 9: return 'b_5_9'
if lv <= 19: return 'b_10_19'
return 'b_20p'
gdf['bucket'] = gdf['levels'].map(bucket_of)
counts = gdf['bucket'].value_counts().reindex(['b_1','b_2_4','b_5_9','b_10_19','b_20p'], fill_value=0)
print(f"{len(gdf):,} buildings retained ({(gdf['name'] != '').sum():,} with names)")
for k, n in counts.items():
print(f" {k:>8}: {n:>7,}")124,354 buildings retained (5,016 with names)
b_1: 8,575
b_2_4: 70,869
b_5_9: 37,487
b_10_19: 5,282
b_20p: 2,141
Build the trio and wire them together¶
We construct the three widgets in order of dependency:
Map first — one
PolygonLayer.from_geopandasper bucket. We need each layer’smodel_idto hand to the dashboard so it can resolve and toggle them.ChartGPU chart — one big scatter of every building.
Dashboard last — receives the map’s layer UUIDs and a precomputed
stats_tableskeyed by bit-string of bucket-on/off state. With 5 buckets that’s2**5 = 32precomputed combinations; the dashboard JS just looks up the current state on every checkbox toggle.
The dashboard talks to the map directly client-side via host.waitForModel(uuid) (static export) or widget_manager.get_model(uuid) (JupyterLab) — no kernel round-trip required.
# Cool→warm palette; alpha at 200 so taller buildings still let the basemap breathe
BUCKET_DEFS = [
{'key': 'b_1', 'label': '1 floor', 'color': [173, 216, 230, 200]},
{'key': 'b_2_4', 'label': '2–4 floors', 'color': [100, 181, 246, 210]},
{'key': 'b_5_9', 'label': '5–9 floors', 'color': [255, 213, 79, 220]},
{'key': 'b_10_19', 'label': '10–19 floors', 'color': [255, 138, 82, 230]},
{'key': 'b_20p', 'label': '20+ floors', 'color': [216, 67, 21, 240]},
]
def rgb_hex(rgba):
return '#{:02x}{:02x}{:02x}'.format(rgba[0], rgba[1], rgba[2])
# --- Map ---
# Pass name/class/levels/height columns into the layer so the lonboard
# tooltip can show them on hover.
HOVER_COLS = ['geometry', 'name', 'class', 'levels', 'height_m']
def make_layer(key, color):
sub = gdf[gdf['bucket'] == key]
return PolygonLayer.from_geopandas(
sub[HOVER_COLS],
get_fill_color=color,
get_line_color=[0, 0, 0, 60],
line_width_min_pixels=0.3,
opacity=1.0,
)
building_layers = {b['key']: make_layer(b['key'], b['color']) for b in BUCKET_DEFS}
m = Map(
layers=[building_layers[b['key']] for b in BUCKET_DEFS],
view_state={
'longitude': -73.985, 'latitude': 40.748,
'zoom': 13.5, 'pitch': 0, 'bearing': 0,
},
show_tooltip=True,
)
# --- Chart: every building, sorted by floors ---
# Plot rank (sorted by floors) vs floors as scatter, colored by bucket.
# Sorting by floors (integer 1..~100) instead of raw height (continuous,
# max 472m) keeps a few supertalls from blowing out the y-axis and
# squashing the rest of the city into a flat line at y=0. The result is
# a clean step-function: each integer y-value is a horizontal band whose
# width = how many buildings have that many floors. Bucket colors match
# the map.
sorted_gdf = gdf.sort_values(['levels', 'height']).reset_index(drop=True)
sorted_gdf['rank'] = np.arange(len(sorted_gdf))
chart = ChartGPUWidget(
title=f"All {len(sorted_gdf):,} NYC buildings, sorted by number of floors",
x_label='building rank (sorted by floors, low → tall)',
y_label='floors',
width=1000, height=420,
show_fps=True,
chart_type='scatter',
)
for b in BUCKET_DEFS:
sub = sorted_gdf[sorted_gdf['bucket'] == b['key']]
if len(sub) == 0:
continue
series = [[float(r), float(lv)] for r, lv in zip(sub['rank'].values, sub['levels'].values)]
chart.add_series(series, series_type='scatter', name=b['label'], color=rgb_hex(b['color']))
# --- Dashboard ---
# Precompute stats for every bucket-on/off combination.
# Key = bit-string of 5 chars in BUCKET_DEFS order, e.g. "11111" = all on.
stats_tables = {}
for state in itertools.product([0, 1], repeat=len(BUCKET_DEFS)):
visible_keys = [b['key'] for b, s in zip(BUCKET_DEFS, state) if s]
sub = gdf[gdf['bucket'].isin(visible_keys)] if visible_keys else gdf.iloc[:0]
key = ''.join(str(s) for s in state)
stats_tables[key] = {
'count': int(len(sub)),
'total_floor_area_m2': int((sub['footprint_m2'] * sub['levels']).sum()),
'mean_levels': round(float(sub['levels'].mean()), 1) if len(sub) > 0 else 0.0,
}
# Bucket definitions including counts, for the legend
bucket_defs_for_ui = [
{**b, 'count': int(counts.get(b['key'], 0))} for b in BUCKET_DEFS
]
dashboard = NYCDashboard(
title='NYC Buildings',
subtitle=f"{len(gdf):,} buildings · Manhattan + LIC + Brooklyn · Overture Maps",
layer_uuids={b['key']: building_layers[b['key']].model_id for b in BUCKET_DEFS},
chart_uuid=chart.model_id,
bucket_definitions=bucket_defs_for_ui,
bucket_states={b['key']: True for b in BUCKET_DEFS},
stats_tables=stats_tables,
)
print(f"map: {len(m.layers)} layers, {sum(counts):,} polygons")
print(f"chart: {len(sorted_gdf):,} points across {len(BUCKET_DEFS)} bucket series")
print(f"dashboard: {len(BUCKET_DEFS)} buckets, {len(stats_tables)} precomputed states")map: 5 layers, 124,354 polygons
chart: 124,354 points across 5 bucket series
dashboard: 5 buckets, 32 precomputed states
The dashboard, the map, and the chart¶
Toggle the height buckets in the dashboard — the map below filters and the stats counters retween. The ChartGPU chart shows every building as a dot in (rank × floors) space, sorted by floors. Each integer floor-count is a horizontal band whose width is how common that floor count is — the broad band at 2-4 floors is most of NYC’s housing stock; the long thin tail to the upper right is the supertall office and residential towers.
display(dashboard)
display(m)
display(chart)