Skip to content

Repository files navigation

County Tracker

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.

Setup (do this first — nothing renders without it)

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.

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

  2. Enable two APIs on that project, under APIs & Services → Enabled APIs:

  3. 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)
  4. 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.
  5. 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.

  6. Copy .env.local.example to .env.local and fill in both values:

    VITE_GOOGLE_MAPS_API_KEY=...
    VITE_GOOGLE_MAPS_MAP_ID=...
    

    .env.local is 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.

Run it

npm install
npm run dev

Scope

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 by npm 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% with mapshaper and 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 match counties.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 by npm run build-adjacency (bounding-box pruning via flatbush, then @turf/boolean-intersects to 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 via npm run crosswalk (--limit=N for a cheap smoke test). Checkpoints to .data-src/crosswalk-checkpoint.json every 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.

Why the Geocoding API is guarded

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:

  1. scripts/guard-no-runtime-geocoding.mjs runs automatically before every npm run dev and npm run build (wired via npm pre* hooks) and fails the command if src/ contains any geocoding or billed place-resolution call.
  2. The app never requests the geocoding or places libraries from the Maps JavaScript API loader, so google.maps.Geocoder and PlacesService are 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.

Stack

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages