An interactive map of NYC neighborhoods by country of birth, inspired by Mapgate.
Interactive map of where foreign-born New Yorkers live, built from ACS 2019–2023 place-of-birth data. Five views: top birthplace, signature groups (location quotient, with statistical ties declared when margins of error overlap), tract-level Mosaic (blended region colors), dot-density Dots, and a by-country/region explorer — plus the city's "Immigrant Enclaves" list audited against the data. Residual census write-in lines ("West Indies", "Other …") count toward totals but never compete as countries; all "× citywide" multiples compare shares of all residents (group share of the neighborhood vs group share of the city).
Place of birth, not ancestry. Unlike ancestry maps (e.g. the NYT's 2026 "American Mosaic"), this counts only people actually born abroad. Ancestry is an identity claim that persists across generations; place of birth is a biographical fact. The Mosaic view borrows the NYT blended-color convention, applied to actual immigrants.
Any static file server works (the page fetches data/*.json lazily for the tract Mosaic and the
full street grid):
python3 -m http.server 8742
# open http://localhost:8742/
git remote add origin git@github.com:<you>/actual-immigrants.git
git push -u origin main
# repo Settings → Pages → deploy from branch `main`, root
index.html is fully self-contained except for the two lazy layers in data/ (tract mosaic,
full streets), which degrade gracefully if absent — the single file also runs as a claude.ai
artifact without them.
The address bar tracks what you're looking at, so a reload, a bookmark or a pasted link restores the same view. Everything is client-side (a URL fragment), so it works on a static host with no routing rules. Copy link to this view copies the current URL; tick short link for a packed code instead.
#v=sig&z=3.4&at=40.751,-73.883 |
readable form — v view, z zoom, at centre lat,lon, sel country/region by name, t prominence threshold, nta pinned neighborhood, f isolated legend group, pins/miss overlays. Omitted keys mean "default", so a hand-written #v=one&sel=Ecuador is a valid link. |
#s=Yk3nQp2Rf1 |
packed form — the same state in ~55 bits of base64url. Opaque, but uniform ~12 chars. |
Both forms parse on load; a garbled packed code falls back to the default view rather than
throwing. Theme is deliberately not in the URL — it's a viewer preference in localStorage,
so a shared link never forces dark mode on the recipient.
FROZEN in the template is an append-only list of country and region names. The packed form
stores positions in it, so reordering or removing an entry silently repoints every existing short
link. Add new names to the end; never reorder. (The readable form stores names, so it's immune.)
| File | What |
|---|---|
index.html |
the map for GitHub Pages (data inlined; doctype/viewport/og/favicon head prepended) |
favicon.svg |
tab icon (four dots in the region palette); also inlined as a data URI in index.html |
nyc-diaspora-map.template.html |
source template (__DATA__ / __ROADS__ placeholders) |
build_mapdata.py |
tract→NTA aggregation → map_data.json (needs openpyxl) |
build_roads.py |
TIGER major roads + names → roads.json (needs pyshp, shapely) |
build_tracts.py |
tract geometry + per-tract data → data/tracts_topo.json, data/tract_pob.json (needs topojson) |
build_streets_full.py |
full S1400 street grid → data/streets_full.json |
enclave_check.py |
audit of the 30 MOIA enclaves against ACS |
b05006/b01003/b04006/b05009_nyc_tracts.dat |
filtered Census FTP extracts (place of birth, population, ancestry, children by parents' nativity) |
tract_nta_xwalk.xlsx |
DCP 2020 tract→NTA crosswalk |
map_data.json |
built page data — aggregation + ages + shoreline-clipped topo (see Rebuilding) |
ages.json |
per-country median age / arrival / share 65+ from PUMS; also inlined via map_data.json |
map_data.json is a committed artifact that three scripts write in sequence — the aggregation,
then the ages payload, then the shoreline clip. Run them in this order or later steps get lost:
python3 build_mapdata.py # writes map_data.json
python3 build_ages.py # adds map_data.json['ages'] + ages.json
# needs nyc_puma20.txt, pums_dict.csv, PUMS extracts (not in repo)
python3 clip_water.py # rewrites map_data.json['topo'] + *_topo.json
# needs nybb.geojson (not in repo)
python3 build_roads.py # needs roads_36*.zip (TIGER 2023 ROADS per county)
python3 build_tracts.py # needs cb_tracts.zip (cartographic boundary tracts)
python3 build_streets_full.py
python3 - <<'EOF'
cur = open('index.html').read()
head = cur[:cur.index('<title>')] # doctype/viewport/og/favicon/beacon — NOT in the template
tpl = open('nyc-diaspora-map.template.html').read()
built = tpl.replace('__DATA__', open('map_data.json').read()).replace('__ROADS__', open('roads.json').read())
open('index.html', 'w').write(head + built)
open('nyc-diaspora-map.html', 'w').write(built) # artifact build, no head
EOF
Two traps, both of which have bitten this repo:
- The template has no
<head>. The doctype, viewport, og: tags, Cloudflare beacon and the favicon data URI live only inindex.html, so the build step above lifts the existing head off the current file rather than regenerating it. Don't replace that with a bareopen('index.html','w'). build_mapdata.pyalone regressesmap_data.json. It rewrites the file from the Census extracts, dropping theageskey and revertingtopoto unclipped. Between 2026-07-11 and this commit the repo was in exactly that state: the committed json had neither, whileindex.htmlhad both, so the documented recipe would have silently removed the median-age/arrival panels and un-clipped every shoreline.ages.jsonis now committed too, so the ages data survives even ifmap_data.jsonis regenerated without its inputs.
Sanity check after any rebuild — this should print True:
python3 -c "
cur=open('index.html').read(); tpl=open('nyc-diaspora-map.template.html').read()
b=tpl.replace('__DATA__',open('map_data.json').read()).replace('__ROADS__',open('roads.json').read())
print(cur[:cur.index('<title>')]+b == cur)"
- Place of birth: ACS 2019–2023 5-year, table B05006, census-tract level, pulled keylessly
from the Census FTP table-based summary files
(
www2.census.gov/programs-surveys/acs/summary_file/2023/table-based-SF/data/5YRData/, one national pipe-delimited file per table, stream-filtered by GEO_ID). Same approach for B01003 (population), B04006 (ancestry), B05009 (children by parents' nativity). - Geography: DCP 2020 NTAs (Socrata
9nt8-h7nd), DCP tract→NTA crosswalk (hm78-6dwm), Census cartographic-boundary tracts, TIGER 2023 ROADS. - B05006 is a nested hierarchy — rollup lines (ending
:) excluded from country logic; UK constituents merged; "China" = mainland; "Other X"/"n.e.c." never count as a country. - MOEs aggregated by root-sum-of-squares; reliability = CV (≤12% ok, 12–40% caution, >40% poor).
- "Signature" = location quotient (neighborhood share ÷ citywide share), min 400 people, CV ≤ 40.
- Mosaic blend: per-tract region weights over 8 world groups, blended in squared-RGB space; opacity scales with foreign-born share of the tract.