A personal record of which US counties you've visited, rendered as a styled overlay on Google Maps. Full architecture and rationale in SPEC.md.
County Tracker relies on Google's data-driven boundary styling, which has to be turned on by hand in the Cloud Console. There is no way to enable it from code, and if you skip this the map will load with zero errors and zero county boundaries — an empty basemap that looks broken but isn't telling you why.
-
Create a Google Cloud project (or use an existing one) and enable billing on it — Maps requires a billing account attached even within the free tier.
-
Enable two APIs on that project, under APIs & Services → Enabled APIs:
- Maps JavaScript API
- Geocoding API (used only by the one-time crosswalk script, never at runtime — see Why the Geocoding API is guarded)
-
Create a Map ID, under Google Maps Platform → Map Management → Map IDs → Create Map ID:
- Map type: JavaScript
- Map style: Vector (required — raster Map IDs can't do data-driven feature styling at all)
-
Enable the Administrative Area Level 2 feature layer on that Map ID's style. Map IDs and Map Styles are separate objects in the console:
- Go to Google Maps Platform → Map Management → Map Styles (a different tab from Map IDs).
- Create a new style, Light mode (boundary data-driven styling is currently restricted to light-mode styles in the console, though it still applies once the map renders in dark mode at runtime).
- On the style's edit page, find the Feature layers section/dropdown and enable Administrative Area Level 2. Save.
- Go back to your Map ID and associate this style with it.
- This is the step most likely to get missed. If the map loads but no county ever highlights on click, this is why.
-
Create an API key under APIs & Services → Credentials, restricted to the two APIs above. Also set Application restrictions (HTTP referrers) once you have a domain, so a leaked key is inert elsewhere.
-
Copy
.env.local.exampleto.env.localand fill in both values:VITE_GOOGLE_MAPS_API_KEY=... VITE_GOOGLE_MAPS_MAP_ID=....env.localis gitignored — never commit real keys.
If either variable is missing, the app fails loudly with a setup screen naming the exact missing variable and the console setting that produces it, instead of silently rendering a blank map.
npm install
npm run dev
Phase 1: map rendering, click/shift-click tier cycling, Alt+drag
box-select, undo/redo, a detail panel, localStorage persistence.
Phase 2: a stats panel — counties visited by tier, % of US land area and
population covered, state completion, longest contiguous run of visited
counties (BFS over a precomputed adjacency graph), and a year scrubber
driven by firstVisited.
Phase 3 (timeline import): drop in a Google Takeout location history
export (any of the three JSON shapes Google has shipped — see below) or
a .gpx track. Parsed and downsampled to one point per 5 minutes, then
matched to counties in a Web Worker (flatbush bounding-box index +
@turf/boolean-point-in-polygon over counties.geojson, fetched lazily
— never loaded on initial page load). Overnight stays (points spanning
~2–6am local) propose tier 4; a >45min dwell proposes tier 2; otherwise
tier 1. Nothing is ever auto-applied — a review screen shows every
proposal with its evidence, and applying never lowers an existing tier
(dates widen instead: earliest firstVisited, latest lastVisited).
Local time is approximated from longitude when the source doesn't supply
a real UTC offset — no timezone lookup, because that would mean either a
bundled timezone-boundary dataset or an external API, and location data
never leaves this machine.
Phase 4 (route planning): paste an encoded polyline (from a Directions
API response or a "share route" link) — decoded and densified locally,
then run through the same worker. Highlights new counties on the route
in amber (reserved exclusively for this), shows a crossed/new count,
the nearest unvisited county to your destination, and "cheapest new
counties" within a ~20mi detour (straight-line there-and-back
approximation). Deliberately does not call the Directions API —
origin/destination text input would need it live, on every route
planned, which is a different cost commitment than the crosswalk's
one-time run; polyline-paste avoids it entirely. An optional NPS unit
boundary toggle overlays public/data/nps-units.geojson (442 units,
fetched from NPS's public ArcGIS service, generalized server-side to
~1.7MB — the full-resolution coastline data is 80MB+).
Phase 5 (sharing): PNG export renders a static choropleth from
counties.geojson directly — not a screenshot of the live map. Google's
vector Maps JS API draws into a WebGL canvas without
preserveDrawingBuffer, so canvas.toDataURL() on it is unreliable in
practice, and capturing Google's basemap tiles for export sits in a
Maps Platform ToS gray area that rendering our own public Census
geometry sidesteps entirely. Share links gzip your visits (fips/tier/
dates only, never notes) via the browser's native CompressionStream
and base64url-encode the result straight into the URL fragment — no
backend, nothing uploaded, not even to us. Friend compare pastes
someone else's link and diffs county lists (only you / only them /
both).
Search field (src/components/CountySearch.tsx): the only way to
reach the 42 independent cities + DC that have no clickable map boundary
(see below) — also the keyboard-reachable county-selection path SPEC.md
§7's quality floor calls for.
Phase 2 needed the real dataset, so that got built too:
public/data/counties.json— all 3,144 counties/equivalents (50 states + DC), from the 2024 Census Gazetteer + 2024 population estimates, built bynpm run build-data. Raw sources land in.data-src/(gitignored — only the derived JSON is committed).public/data/counties.geojson— cartographic boundaries (cb_2024_us_county_500k), simplified 11% withmapshaperand coordinate precision rounded to 4 decimals to land under the ~3MB budget (spec's suggested 15% landed at 3.6MB — dropped to 11% to fit). Feature count is verified to exactly matchcounties.json's record count. Not fetched by the app itself — build-time only, feeding the adjacency script below.public/data/adjacency.json— a{fips: fips[]}shared-border graph, built bynpm run build-adjacency(bounding-box pruning viaflatbush, then@turf/boolean-intersectsto confirm real adjacency). The only counties with zero neighbors are genuine islands (Hawaii's three counties, Nantucket, Martha's Vineyard, Staten Island, the San Juan Islands, Aleutians West) — a good sanity check that it's correct.scripts/build-crosswalk.ts— reverse-geocodes every county's Census internal point to a real Google place ID vianpm run crosswalk(--limit=Nfor a cheap smoke test). Checkpoints to.data-src/crosswalk-checkpoint.jsonevery 50 resolved counties, so a crash resumes instead of restarting.scripts/crosswalk-overrides.json({fips: placeId}) is applied before spending an API call, for anything that needs a manual entry.
Known gap: 42 independent cities + DC have no clickable map boundary.
result_type=administrative_area_level_2 returns ZERO_RESULTS for
Virginia's ~40 independent cities, Baltimore, St. Louis, Carson City, and
DC — Google's geocoder classifies these as locality, not AA2. The
crosswalk falls back to a locality geocode for these automatically, so
they're complete and correctly identified in counties.json. But this
isn't just a geocoding quirk: verified directly in a browser that
Google's AA2 map feature layer has no clickable boundary there at all,
regardless of which place_id is assigned — county-equivalents are
correctly in the dataset (SPEC.md §6: "don't filter on the word
'County'"), but click-to-cycle can't reach them on the map itself. The
search field is the way in for these — type "Richmond city" and it's
reachable exactly like any other county, tier picker and all.
Vintage: 2024 Gazetteer + 2024 cartographic boundaries + 2024 population estimates, 50 states + DC (Puerto Rico and other territories excluded). Connecticut reflects the Gazetteer's current planning-region boundaries, not pre-2022 counties (SPEC.md §6) — picked one vintage and left it consistent rather than special-casing CT everywhere.
Re-running the pipeline from scratch:
npm run build-data # counties.json from Census sources
npm run build-geojson # counties.geojson from the cb_2024_us_county_500k shapefile in .data-src/
npm run build-adjacency # adjacency.json from counties.geojson
npm run crosswalk # fills in placeId for every county — costs real API calls
npm run build-nps-units # nps-units.geojson from NPS's public ArcGIS service (Phase 4, optional overlay)
build-geojson and build-nps-units expect their raw sources already
downloaded into .data-src/ — see the comment at the top of each script
for the exact curl command.
The single easiest way to get a surprise bill on this project is calling the Geocoding API from the browser instead of the one-time, by-hand crosswalk script. Two independent layers prevent it:
scripts/guard-no-runtime-geocoding.mjsruns automatically before everynpm run devandnpm run build(wired via npmpre*hooks) and fails the command ifsrc/contains any geocoding or billed place-resolution call.- The app never requests the
geocodingorplaceslibraries from the Maps JavaScript API loader, sogoogle.maps.GeocoderandPlacesServiceare simply undefined in the browser — an accidental call throws immediately instead of silently billing.
scripts/build-crosswalk.ts is the one place allowed to call the
Geocoding API, run by hand via npm run crosswalk.
Vite + React + TypeScript, @vis.gl/react-google-maps. zod validates
anything that comes back out of localStorage or a pasted share link.
No CSS framework — plain CSS with custom properties (SPEC.md §7).
Point-in-polygon (Phase 3 import, Phase 4 routes) runs in a Web Worker:
flatbush for the bounding-box index, @turf/boolean-point-in-polygon
to confirm the match. Sharing (Phase 5) leans entirely on browser
built-ins — CompressionStream for share links, <canvas> for the PNG
export — no new runtime dependencies for either.
Build-time only (never shipped to the browser): mapshaper simplifies
the county boundary shapefile, @turf/boolean-intersects builds the
adjacency graph.