Skip to content

Latest commit

 

History

65 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Actual Immigrants

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.

Run it

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/

GitHub Pages

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.

Sharing a view

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.)

Files

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

Rebuilding

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:

  1. The template has no <head>. The doctype, viewport, og: tags, Cloudflare beacon and the favicon data URI live only in index.html, so the build step above lifts the existing head off the current file rather than regenerating it. Don't replace that with a bare open('index.html','w').
  2. build_mapdata.py alone regresses map_data.json. It rewrites the file from the Census extracts, dropping the ages key and reverting topo to unclipped. Between 2026-07-11 and this commit the repo was in exactly that state: the committed json had neither, while index.html had both, so the documented recipe would have silently removed the median-age/arrival panels and un-clipped every shoreline. ages.json is now committed too, so the ages data survives even if map_data.json is 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)"

Data sources & method notes

  • 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.

About

Interactive map of NYC neighborhoods by country of birth, because I couldn't stand to hear one more complaint about Little Italy

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages