An interactive WebAssembly-powered map for exploring commute flow patterns across Utah, built entirely on open-source tools — no ArcGIS or proprietary dependencies.
Live app: https://wfrc.utah.gov/maps/regional-commuter-flows/
GitHub Pages mirror: https://wfrcanalytics.github.io/APP-WFRC-Commute-Patterns/
| Light mode | Dark mode |
|---|---|
![]() |
![]() |
- Arc flow map — curved arcs connect origins and destinations; arc width and opacity scale with commuter volume
- Four subject area types — select any city, county, Utah House district, or Utah Senate district as the subject of analysis
- Two map zone levels — display destination choropleth and flow arcs aggregated at city or county level
- Both flow directions — "where residents work" and "where workers live"
- 22 years of data — LEHD LODES data from 2002–2023; switch years from the header
- Six chart panels (right sidebar, collapsible and resizable):
- Commute Balance — diverging bar showing top destinations and origins simultaneously
- Flow Diagram — bilateral alluvial diagram (top 5 + Others per side) with a flow summary strip showing total inflow, live-and-work, and total outflow proportionally
- Means of Transportation — ACS 5-Year Estimates breakdown (drove alone, carpool, transit, walk, WFH, other) for the selected area
- Travel Time to Work — ACS 5-Year Estimates distribution across six time bands (< 10 min through 60+ min)
- Commute Reach — distance-band distribution (< 10 mi, 10–25 mi, 25–50 mi, 50+ mi) with block-level weighted mean and median commute distance
- Industry Mix — stacked bar across top-5 destinations with inflow/outflow toggle
- Export — every chart exports as PNG or CSV
- Clickable zone polygons — click any city or county polygon on the map to select it directly
- Layer controls — toggle flow lines and polygon choropleth; set a minimum-commuter threshold for flow lines
- Gradient color legend — top-left overlay shows the flow color scale matching arc colors exactly
- Map controls — zoom, compass with pitch visualization, reset tilt & north, reset to full Utah view, geolocate, fullscreen, scale bar
- Light / dark mode — full theme switching including map tiles, arc colors, chart colors, map controls, navbar logo, and browser favicon
- Fully client-side — DuckDB-WASM queries pre-processed Parquet files directly in the browser; no server or API keys required
Source: US Census LEHD LODES 8, Origin-Destination Employment Statistics for Utah, 2002–2023.
Coverage: Statewide Utah — all cities (Census-designated places), all 29 counties, all 75 Utah House districts, and all 29 Utah Senate districts.
Geography: Block-level OD pairs are aggregated to city, county, and legislative district level using the LEHD geographic crosswalk and Census TIGER/Line 2026 block-to-district overlays. Distance columns (dist_wsum, dist_n) are pre-aggregated at block level using haversine distances, enabling exact weighted mean commute distances at any geography level. Centroids are derived from Census TIGER/Line 2026 polygon shapefiles.
Pre-processed data files are committed to the repo under data/ so the app runs with no server-side processing:
| File | Description |
|---|---|
data/manifest.json |
Available years and default year |
data/lehd/<year>/city_flows.parquet |
City-to-city OD pairs — commuter counts, age/earnings/industry breakdowns, distance bands, block-level distance sums |
data/lehd/<year>/county_flows.parquet |
County-to-county OD pairs (same columns) |
data/lehd/<year>/district_flows.parquet |
House- and senate-district OD pairs carrying all four geography columns for cross-type queries |
data/lehd/<year>/city_meta.json |
City centroids (lat/lon from TIGER polygons) |
data/lehd/<year>/county_meta.json |
County centroids |
data/lehd/<year>/house_meta.json |
House district centroids and FIPS codes |
data/lehd/<year>/senate_meta.json |
Senate district centroids and FIPS codes |
data/city_boundaries.geojson |
City polygon boundaries for choropleth |
data/county_boundaries.geojson |
County polygon boundaries for choropleth |
data/house_boundaries.geojson |
Utah House district polygon boundaries |
data/senate_boundaries.geojson |
Utah Senate district polygon boundaries |
data/acs/<year>/acs_city.json |
ACS 5-Year Estimates keyed by 7-digit place FIPS (~320 Utah places) — transportation mode and travel time distributions |
data/acs/<year>/acs_county.json |
ACS 5-Year Estimates keyed by 5-digit county FIPS (29 Utah counties) |
data/acs/<year>/acs_house.json |
Estimates keyed by 5-digit state-legislative-lower FIPS (derived — see ACS estimates and margins of error) |
data/acs/<year>/acs_senate.json |
Estimates keyed by 5-digit state-legislative-upper FIPS (derived) |
data/acs/<year>/acs_area.json |
Derived estimates keyed by display type then area name: city (" Unincorporated"), small/medium/large/super planning areas, workshop, mag_workshop |
| Layer | Library |
|---|---|
| Build | Vite |
| Map | MapLibre GL JS + CARTO GL vector tiles |
| Flow visualization | Flowmap.gl FlowmapLayer via @deck.gl/mapbox |
| Charts | Custom SVG / DOM — no charting library |
| In-browser data | DuckDB-WASM querying Parquet files |
| Data pipeline | Python — pandas, GeoPandas, PyArrow |
| Python env | uv |
| Deployment | GitHub Actions → WFRC FTP + GitHub Pages |
npm installuv syncThe pre-processed data/ files are already committed. Only re-run this if you want to refresh with a newer LEHD year or change the geographic scope.
uv run python scripts/process_data.pyThe script downloads LEHD OD files and Census TIGER shapefiles and writes the output files into data/.
npm run devOpen http://localhost:5173/APP-WFRC-Commute-Patterns/.
Every push to master triggers two parallel deploy jobs via GitHub Actions (.github/workflows/deploy.yml):
| Target | URL | Build base |
|---|---|---|
| WFRC web server (FTP) | https://wfrc.utah.gov/maps/regional-commuter-flows/ | ./ (relative) |
| GitHub Pages (mirror) | https://wfrcanalytics.github.io/APP-WFRC-Commute-Patterns/ | /APP-WFRC-Commute-Patterns/ |
The FTP job requires three repository secrets: FTP_HOST, FTP_USERNAME, FTP_PASSWORD.
To enable GitHub Pages on a new repository fork:
- Go to Settings → Pages → Source and select GitHub Actions.
- Push to
master— the workflow builds and deploysdist/to thegithub-pagesenvironment.
├── index.html # App shell
├── vite.config.js # Vite build config
├── package.json
├── pyproject.toml # Python data pipeline dependencies (uv)
├── uv.lock # Pinned Python dependency tree
│
├── src/
│ ├── main.js # App entry — state, boot, visualization loop
│ ├── db.js # DuckDB-WASM init and query functions
│ ├── map.js # MapLibre + Flowmap.gl + map controls
│ ├── sidebar.js # Sidebar UI — search, toggles, stats, Top 10
│ ├── charts.js # ECharts panels — all 5 charts + exports
│ └── styles/
│ ├── main.css # Layout and CSS custom properties (light/dark tokens)
│ ├── sidebar.css # Sidebar-specific styles
│ ├── charts.css # Right panel chart section styles
│ └── toolbar.css # Map overlay toolbar + MapLibre control theme overrides
│
├── data/ # Pre-processed data files (committed)
│ ├── manifest.json # Available years list
│ ├── city_boundaries.geojson
│ ├── county_boundaries.geojson
│ ├── house_boundaries.geojson
│ ├── senate_boundaries.geojson
│ ├── custom_places.gpkg # Hand-drawn custom place boundaries (e.g. Hill Air Force Base)
│ ├── acs/ # ACS 5-Year Estimates (one directory per year)
│ │ └── <year>/
│ │ ├── acs_city.json
│ │ ├── acs_county.json
│ │ ├── acs_house.json
│ │ ├── acs_senate.json
│ │ └── acs_area.json
│ └── lehd/ # LEHD LODES flow data (one directory per year)
│ └── <year>/ # 2002–2023
│ ├── city_flows.parquet
│ ├── county_flows.parquet
│ ├── district_flows.parquet
│ ├── city_meta.json
│ ├── county_meta.json
│ ├── house_meta.json
│ └── senate_meta.json
│
├── scripts/
│ ├── fetch_sources.py # Downloads/validates TIGER + LEHD crosswalk sources into data/cache/
│ ├── process_data.py # Offline data pipeline (LEHD + TIGER + ACS)
│ ├── fetch_acs.py # Fetches ACS 5-Year Estimates via Census API (published values + margins of error)
│ ├── acs_derive.py # Builds ACS estimates for areas the Census doesn't publish, from block groups / tracts
│ ├── verify_acs_derive.py # Back-tests acs_derive against every published place (tier cutoffs come from this)
│ ├── custom_places.py # Config-driven add/remove of places on top of the Census geography
│ ├── custom_places_config.json # "remove" (Census places) and "add" (custom/UGRC places) lists
│ ├── verify_custom_places.py # LEHD coverage audit for custom places
│ └── screenshot.cjs # Playwright — boots the dev server, captures light/dark screenshots into docs/, tears it down (--port N to override 5174)
│
├── public/
│ ├── favicon-light.svg # Theme-aware favicon (light mode)
│ └── favicon-dark.svg # Theme-aware favicon (dark mode)
│
├── assets/
│ └── logo/
│ ├── WFRC_logo_abbreviated_color_transparent.png # Navbar logo (light mode)
│ └── WFRC_logo_abbreviated_white_transparent.png # Navbar logo (dark mode)
│
└── .github/
└── workflows/
└── deploy.yml # GitHub Actions → WFRC FTP + GitHub Pages
Run uv run scripts/fetch_sources.py first to download and validate the TIGER and crosswalk inputs (safe to re-run; the TIGER vintage is the single TIGER_YEAR in scripts/geo_utils.py).
scripts/process_data.py runs entirely offline and produces the committed data/ files:
- Downloads
ut_od_main_JT00_<year>.csv.gzfrom LEHD (block-level OD records for all of Utah) - Downloads the LEHD geographic crosswalk (
ut_xwalk.csv.gz) to map blocks → city and county names - Labels unincorporated blocks as
"[County] Unincorporated" - Computes block-level haversine distances and pre-aggregates
dist_wsum(sum ofworkers × miles) anddist_n(worker count with valid coordinates) for exact weighted mean commute distances at any geography level - Aggregates to city→city, county→county, and legislative district OD pairs, summing all job-count and distance columns
- Downloads Census TIGER 2026 Place, County, and State Legislative District shapefiles for Utah; overlays block centroids to assign house/senate districts
- Computes polygon centroids in Utah State Plane (EPSG:26912) and projects to WGS84
- Exports Parquet files (Snappy-compressed), JSON metadata, and GeoJSON boundaries for all four geography levels
- Fetches ACS 5-Year Estimates via the Census API (tables B08301/B08601 for means of transportation, B08303/B08603 for travel time to work) for cities and counties (published values, each with its margin of error) and, via
acs_derive.py, for places missing from a year, Unincorporated areas, House/Senate districts, and the planning and workshop areas; writesdata/acs/<year>/acs_{city,county,house,senate,area}.json; skip with--skip-acsif a Census API key is unavailable. Regenerate every year withuv run scripts/fetch_acs.py --force
Edit scripts/custom_places_config.json; no code changes are needed. The two lists are independent:
remove: Census places to drop, by FIPS (e.g. CDPs merged into a newly incorporated city). The polygon disappears and the blocks fall back to " Unincorporated".add: places Census/LEHD don't have. Give asourceof{"type": "ugrc_municipal", "name": "<UGRC NAME>"}to use UGRC's municipal boundaries, or omit it to use a polygon of the same name indata/custom_places.gpkg.enable: truemakes it a normal selectable city (needs a 7-digitplace_fips);enable: falserenders an info-only polygon.
Then run uv run scripts/fetch_sources.py (caches the whole UGRC layer; --refresh-ugrc re-pulls it), uv run scripts/process_data.py --boundaries, process_data.py --year N --skip-acs for every year, uv run scripts/fetch_acs.py --force (derived ACS estimates follow the new geography), and uv run scripts/build_trends.py. The edit is applied to the loaded crosswalk (patch_xwalk): no block is ever dropped, and the run fails if an added polygon would take blocks from a real place that isn't listed under remove, or if flow names, metadata and boundaries disagree. uv run scripts/verify_custom_places.py shows which Census places a polygon overlaps. Added places get derived ACS estimates (see below), and a boundary is applied to every year, including years before the place existed.
Every ACS value in data/acs/ carries its 90% margin of error (moe), where it came from (src: native = published by the Census, derived = built by acs_derive.py) and a reliability flag (q: ok / low).
The Census publishes commute tables for places and counties only. For everything else — places missing from a given year, " Unincorporated", custom places such as Ogden Valley City, House/Senate districts, planning and workshop areas — acs_derive.py builds the estimate from the smallest published unit: block groups (ACS 2013+) or tracts (2010–2012), weighted by each unit's share of resident workers (LODES RAC, same year) living inside the area. Unit IDs must match the block vintage: ACS 2020+ uses 2020 block groups with the LODES8 crosswalk, ACS 2010–2019 uses 2010 geography with the LODES7 crosswalk (ut_xwalk_lodes7.csv.gz). ACS 2009 sits on 2000 tracts, so nothing is derived for it. Only residence-based tables can be derived: the Census publishes the workplace tables (B08601/B08603) for places and counties only (block-group and tract values are empty), so workplace views of derived areas stay blank.
Derived margins combine the sampling error of the units with an allocation term for units that straddle the area boundary. Derived values are tiered on the margin of error as a fraction of the estimate (0.20 is also the Census's own ~12% CV): up to 0.20 shown as an estimate, up to 0.30 shown and flagged low reliability, above that suppressed ({"suppressed": true}). Tract years are coarser, so their cutoffs are halved (0.10 / 0.15). Published Census values are never hidden, only flagged. The cutoffs come from uv run scripts/verify_acs_derive.py, which re-derives every published place and compares: median error in the total is about 1–2% in the top tier, 3–5% in the second and 8–18% in the low-reliability tier, and the published value falls inside the derived margin 90–100%, 76–91% and 63–78% of the time respectively (the margin is optimistic for small places, which is why the lower tiers are flagged). Re-run it after changing the method.
uv run scripts/fetch_sources.py --only rac pre-downloads the LODES residence files; acs_derive.py also fetches a missing one on first use.
- Commute data: US Census Bureau LEHD Program
- Commute characteristics: US Census Bureau American Community Survey 5-Year Estimates
- Geography: US Census Bureau TIGER/Line Shapefiles
- Map tiles: CARTO / OpenStreetMap contributors

