A temporal heatmap of San Francisco's metered parking. Pick any day-of-week and hour, and the map shows you typical occupancy across every metered block in the city — built from ~206M meter transactions pulled directly from the SF Open Data SODA API.
Live: https://sfparking.wolfie.gg
- 168-slot weekly profile per block: 7 days × 24 hours of occupancy, computed from real meter sessions
- Multi-tier visualization: heatmap at city zoom → 3D columns at neighborhood zoom → block-level paths and individual meter dots at street zoom
- Time playback: scrub through the week or hit play to watch demand pulse
- Block detail panel: per-block hour-by-hour breakdown, supply, enforcement schedule
- Comparison mode: pin a reference time, see deltas vs. any other slot
- Search + radius: find a specific address and see what parking looks like nearby
- Isochrone mode: pick an origin and see how far you can drive/bike/walk in N minutes (uses local Valhalla routing — see Optional below)
- Bike share view: overlay Bay Wheels station demand and visualize correlation with parking pressure
- Deeplinkable URL state: every selection (time, view, block, search, isochrone) is in the URL, so any view is shareable
All data comes from public, unauthenticated endpoints. There are no API keys to configure.
| Source | Dataset | Used for |
|---|---|---|
| SF Open Data SODA API | 8vzz-qzz9 (Parking Meters) |
Active meter locations, block centroids |
| SF Open Data SODA API | imvp-dq3v (Meter Operating Schedules and Transaction Counts) |
~206M session records aggregated into 168-slot occupancy profiles |
| SF 311 service requests | (via SODA) | Off-hours parking pressure scores |
| Bay Wheels GBFS | bike share station status feed | Station capacity and trip data for the bike view |
The map basemap is CARTO Dark Matter (open vector tiles, no token).
- Frontend: Vite 7 + React 19 + TypeScript + Tailwind CSS v4
- Mapping: deck.gl v9 layers on top of MapLibre GL via
react-map-gl - Pipeline: Python 3 standard library only — no
requirements.txtneeded - Routing (optional): Valhalla running locally in Docker for isochrone computation
# 1. Install JS deps
pnpm install
# 2. Build the data (one-time, takes a few minutes)
pnpm fetch-meters # ~28k metered blocks → public/data/meter_locations.json
pnpm fetch-enforcement # block-level enforcement schedules
pnpm fetch-311 # 311 pressure scores
pnpm aggregate # paginated GROUP BY over the full transaction dataset
# Or just run the whole pipeline:
pnpm pipeline
# 3. Start the dev server
pnpm devThen open http://localhost:5173.
sf-parking-heatmap/
├── public/data/ # Generated JSON consumed by the frontend (committed)
│ ├── meter_locations.json
│ ├── parking_week.json # The 168-slot occupancy profiles (~3 MB)
│ ├── enforcement_schedules.json
│ ├── pressure_311.json
│ ├── bike_week.json # Bay Wheels demand (optional)
│ └── isochrones/ # Pre-computed Valhalla isochrones (gitignored)
├── scripts/ # Python data pipeline
│ ├── fetch_meter_locations.py
│ ├── fetch_enforcement_schedules.py
│ ├── fetch_311_pressure.py
│ ├── fetch_parking_supply.py
│ ├── aggregate_parking.py # paginated SODA GROUP BY → weekly profiles
│ ├── compute_block_paths.py # PCA-aligned 2-point block geometries
│ ├── aggregate_bike_trips.py
│ ├── build_speed_profiles.py
│ └── compute_isochrones.py # batch Valhalla calls
├── src/
│ ├── App.tsx
│ ├── components/ # Map, panels, controls, tooltips
│ ├── hooks/ # Data loading, time slot, URL state, isochrones
│ ├── layers/ # deck.gl layer factories per zoom tier
│ ├── lib/ # SODA client, color scales, geo helpers
│ └── types.ts
└── docker-compose.yml # Optional Valhalla service for isochrones
The transaction dataset (imvp-dq3v) gives one row per paid session with street_block, session_start_dt, etc. The pipeline:
- Aggregates server-side with
date_extract_dow()anddate_extract_hh()over a 90-day window —aggregate_parking.pymakes a few paginated GROUP BY calls instead of pulling raw rows - Maps SODA day-of-week (1=Sun..7=Sat) to ISO (0=Mon..6=Sun)
- Converts session counts to occupancy ratio:
(sessions_per_week × avg_session_hours × compliance_factor) / meter_count, clamped to[0, 1]AVG_SESSION_HOURS = 1.2(SFMTA average)COMPLIANCE_FACTOR = 1.33(accounts for unpaid parkers)
- Blends in 311 pressure scores for off-hours when meters aren't enforced
The result is a 168-element array per block (dow * 24 + hour) shipped as a single JSON file.
pnpm dev # Vite dev server
pnpm build # Production build (tsc -b && vite build)
pnpm lint # ESLint
pnpm preview # Preview the built bundle
# Data pipeline
pnpm fetch-meters # Active meter locations
pnpm fetch-enforcement # Enforcement schedules
pnpm fetch-311 # 311 pressure data
pnpm fetch-supply # Total parking spaces per block
pnpm compute-paths # PCA block geometry
pnpm aggregate # Aggregate sessions → weekly profiles
pnpm aggregate-bikes # Bay Wheels demand profiles
pnpm pipeline # Run the core pipeline end-to-end
pnpm pipeline-full # Core pipeline + speed profiles + isochronesThe isochrone view (drive/bike/walk reachability from any point) needs a routing engine. The repo includes a docker-compose.yml for Valhalla:
docker compose up -d # Downloads CA OSM extract on first run
pnpm build-speed-profiles # Cluster historical speeds into 6 profiles
pnpm compute-isochrones # Pre-compute isochrones for the gridIf you don't care about isochrones, skip this — the app degrades gracefully.
- Occupancy is an estimate. It uses a fixed
AVG_SESSION_HOURSand aCOMPLIANCE_FACTORfor unpaid parkers. Both are tunable inscripts/aggregate_parking.py. - Only metered blocks. Non-metered streets aren't in the dataset.
- Typical week, not real-time. The pipeline aggregates the trailing 90 days into a typical-week profile. There's no live feed.
enforcedmask is per-block. During non-enforced hours the heatmap blends in 311 pressure scores rather than using meter sessions.
MIT — see LICENSE.
- DataSF for publishing the meter transaction dataset
- deck.gl, MapLibre, and CARTO basemaps for the open mapping stack
- Valhalla for the routing engine
