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.

10. NYC Buildings — lonboard + ChartGPU + a custom dashboard

Three widgets on one page, all backed by the same dataset of 124K NYC buildings:

  1. Custom dashboard — height-bucket toggles + live stats counters. Toggle a bucket and the matching map layer disappears; the counters retween.

  2. Lonboard map — every building footprint as a real polygon, colored by height. Five PolygonLayers (one per bucket) so the dashboard can flip each layer’s visible trait independently.

  3. 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:

  1. Map first — one PolygonLayer.from_geopandas per bucket. We need each layer’s model_id to hand to the dashboard so it can resolve and toggle them.

  2. ChartGPU chart — one big scatter of every building.

  3. Dashboard last — receives the map’s layer UUIDs and a precomputed stats_tables keyed by bit-string of bucket-on/off state. With 5 buckets that’s 2**5 = 32 precomputed 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)