Interactive heatmap of OneBusAway transit stop usage in the Puget Sound region. Combines Google Analytics usage data with GTFS transit data.
You can view the visualization with the latest batch of data at opentransitsoftwarefoundation.org/onebusaway/visualize.
Generate a standalone HTML file using the standalone script:
npm install
npm run generate -- --credentials ./path/to/google-service-account.json
open heatmap.htmlSee Standalone Generation for full options.
- Heatmap view shows transit stop usage intensity with a plasma color scale
- Toggle between Total Riders and Times Viewed metrics
- Zoom in to see individual stops as colored circles
- Hover over stops at high zoom to see name and statistics
- Embeddable - works in iframes as a standalone page
There are two ways to generate heatmap data:
| Method | Best For | Requirements |
|---|---|---|
| Cloudflare Worker | Automated daily updates, public hosting | Cloudflare account, R2 bucket |
| Standalone Script | One-off generation, sharing via email, local development | Node.js 18+, Google Cloud service account JSON |
Before using either method, you need Google Cloud credentials to access the GA4 Data API.
- Go to Google Cloud Console
- Click Select a project → New Project
- Name it (e.g., "OBA Analytics") and click Create
- Select your new project from the project dropdown
- Go to APIs & Services → Library
- Search for "Google Analytics Data API"
- Click on it and click Enable
- Go to APIs & Services → Credentials
- Click Create Credentials → Service account
- Fill in:
- Service account name:
oba-analytics(or similar) - Service account ID: auto-generated
- Service account name:
- Click Create and Continue
- Skip the optional steps (no roles needed) → Done
- Click on your newly created service account
- Go to the Keys tab
- Click Add Key → Create new key
- Select JSON format
- Click Create - the file will download automatically
- Store this file securely (it contains private credentials)
The JSON file will look like:
{
"type": "service_account",
"project_id": "your-project-id",
"private_key_id": "...",
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
"client_email": "oba-analytics@your-project.iam.gserviceaccount.com",
"client_id": "...",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
...
}- Go to Google Analytics
- Select your property
- Click Admin (gear icon) → Property Access Management
- Click + to add user
- Enter the service account email (from your JSON file's
client_emailfield) - Grant Viewer role
- Uncheck "Notify new users by email" (service accounts can't receive email)
- Click Add
The worker expects these custom dimensions to be configured in GA4:
| Dimension Name | Scope | Description |
|---|---|---|
RegionName |
User | Transit region name (e.g., "Puget Sound") |
item_id |
Event | Stop ID in format {agency}_{stopId} |
These should already be configured if you're using OneBusAway's standard analytics setup.
Generate a self-contained HTML file that can be opened locally, shared via email, or hosted on any static file server.
- Node.js 18+ (required for Web Crypto API)
- Google Cloud service account JSON file (see Google Analytics Setup above)
npm install# Generate with defaults (Puget Sound region)
npm run generate -- --credentials ./service-account.json
# Custom output file
npm run generate -- --credentials ./service-account.json --output my-heatmap.html
# Different GA4 property and GTFS source (for other regions)
npm run generate -- \
--credentials ./service-account.json \
--property-id 123456789 \
--gtfs-url https://example.com/gtfs.zip| Option | Short | Required | Default | Description |
|---|---|---|---|---|
--credentials |
-c |
Yes | - | Path to service account JSON file |
--output |
-o |
No | heatmap.html |
Output HTML file path |
--property-id |
- | No | 179560067 |
GA4 Property ID |
--gtfs-url |
- | No | Puget Sound URL | GTFS zip URL |
--help |
-h |
No | - | Show help message |
The script generates a single HTML file (~50KB + data size) that:
- Contains all CSS, JavaScript, and data inline
- Requires no server - open directly in a browser
- Uses CARTO basemap tiles (requires internet for map tiles)
- Can be shared via email or hosted on any static server
For automated daily updates and public hosting, deploy the Cloudflare Worker.
- Cloudflare account
- Node.js 18+
- Google Cloud service account JSON (see Google Analytics Setup above)
npm installnpm install -g wrangler
wrangler loginwrangler r2 bucket create analytics-reportsOr create via the Cloudflare dashboard under R2 Object Storage.
If using a different bucket name, update bucket_name in wrangler.toml:
[[r2_buckets]]
binding = "analytics_reports"
bucket_name = "your-bucket-name"# Paste the entire contents of your service account JSON when prompted
wrangler secret put GOOGLE_SERVICE_ACCOUNT_JSONnpm run deploy| Endpoint | Description |
|---|---|
/health |
Health check - returns "OK" |
/generate |
Manual trigger - generates and stores heatmap.json |
/view |
Live visualization - generates data and renders HTML |
/json |
Returns the stored heatmap.json from R2 |
The worker automatically runs daily at 6 AM UTC. Edit wrangler.toml to change:
[triggers]
crons = ["0 6 * * *"]# Start local development server
npm run dev
# Test endpoints
curl http://localhost:8787/health
curl http://localhost:8787/generate
curl http://localhost:8787/viewTo serve the generated heatmap.json from R2:
Option 1: R2 Custom Domain (Recommended)
- Go to Cloudflare dashboard → R2 → your bucket
- Click Settings → Public access
- Connect a custom domain (e.g.,
data.example.com)
Option 2: R2 Public URL
- Enable public access in R2 bucket settings
- Use the provided
*.r2.devURL
Option 3: Worker Proxy
The /json endpoint serves the stored data through the worker.
[vars]
GA_PROPERTY_ID = "179560067"
GTFS_URL = "https://gtfs.sound.obaweb.org/prod/gtfs_puget_sound_consolidated.zip"| Variable | Description |
|---|---|
GA_PROPERTY_ID |
Google Analytics 4 property ID |
GTFS_URL |
URL to GTFS zip file containing stops.txt |
GOOGLE_SERVICE_ACCOUNT_JSON |
Service account credentials (secret) |
heatmap.json contains a timeframe and array of data points:
{
"timeframe": "Nov 22 2025 - Jan 22 2026",
"generatedAt": "2025-01-22T06:00:00.000Z",
"dataPoints": [
{
"stop_id": "1-575",
"stop_name": "3rd Ave & Pike St",
"stop_lat": 47.609642,
"stop_lon": -122.337585,
"active_users": 5863,
"event_count": 29214
}
]
}| Field | Description |
|---|---|
timeframe |
Human-readable date range (rolling 90 days) |
generatedAt |
ISO 8601 timestamp of generation |
stop_id |
GTFS stop ID (format: {agency}-{id}) |
stop_name |
Human-readable stop name |
stop_lat |
Latitude coordinate |
stop_lon |
Longitude coordinate |
active_users |
Unique users who viewed this stop |
event_count |
Total view events for this stop |
- Verify your service account JSON is valid and complete
- Check that the Google Analytics Data API is enabled in Google Cloud Console
- Ensure the JSON file contains
client_email,private_key, andtoken_uri
- Verify the service account has Viewer access to the GA4 property
- Check that you're using the correct Property ID (not Measurement ID)
- Property ID is numeric (e.g.,
179560067), notG-XXXXXXXX
- Verify the custom dimension names match your GA4 configuration
- Check Admin → Custom definitions in GA4 for exact names
- Check that the date range (rolling 90 days) has data
- Verify the region filter matches exactly ("Puget Sound")
- Try querying GA4 directly in the Google Analytics UI
- Ensure you're using Node.js 18 or later:
node --version - The Web Crypto API is required for JWT signing
- Verify the GTFS URL is accessible and returns a valid ZIP file
- Check that the ZIP contains
stops.txt
- Google Analytics: GA4 Data API (rolling 90-day window)
- GTFS: Auto-downloaded from configured URL
- Map Tiles: CARTO Dark Matter (no API key required)
┌─────────────────────────────────────────────────────────────────┐
│ Data Pipeline │
├─────────────────────────────────────────────────────────────────┤
│ │
│ GA4 API ──┐ │
│ ├──► Worker/Script ──► heatmap.json ──► index.html │
│ GTFS URL ─┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Worker endpoints:
/generate → triggers pipeline, stores to R2
/view → generates on-the-fly, returns HTML
/json → returns stored heatmap.json
Standalone script:
generate-standalone.ts → reads credentials file, outputs HTML
Apache 2.0 - see LICENSE for details.
