← Back to portfolio

Expo · TypeScript · Node.js · GTFS

CTA Transit
Tracker

A cross-platform real-time map for Chicago's CTA buses and 'L' trains. Live vehicle positions are polled from the CTA Bus and Train Tracker APIs, smoothly interpolated between updates, and rendered over official route geometries pulled from the GTFS feed.

Expo / React Native TypeScript Node.js CTA API + GTFS
View on GitHub How it works

// flow

How it works.

01
🛰️
Backend polls CTA
An Express server hits the CTA Bus and Train Tracker APIs every 15 seconds, caching the responses in-memory so the frontend can hammer it without blowing the upstream rate limits.
02
📦
GTFS → GeoJSON
A one-time script parses the static GTFS feed into optimized GeoJSON shapes for every route. The static bundle is served with ETag support and a 24-hour cache header.
03
🗺️
Map renders vehicles
The Expo app fetches both feeds from the backend and drops vehicle markers and colored route polylines onto a react-native-maps canvas that works on Web, iOS, and Android.
04
🧭
Dead-reckoning tween
Between poll cycles, Turf.js interpolates each bus forward from its last known position using heading and speed, so markers glide smoothly instead of teleporting every 15 seconds.

// capabilities

What it can do.

🚌

Live Buses & Trains

Every active CTA bus and 'L' train appears on the map with its current position, refreshed on a 15-second cadence from the official trackers.

Smooth Interpolation

Buses use heading- and speed-aware dead-reckoning via Turf.js; trains fall back to linear interpolation. Either way, vehicles move instead of jumping.

🎨

Official Route Colors

Route polylines are drawn straight from the GTFS shapes feed in the actual CTA branding colors — Red, Blue, Brown, Green, Orange, Purple, Pink, Yellow.

🎛️

Per-Route Toggles

An expandable overlay panel lets riders show or hide individual routes, with bulk select-all / clear-all controls for quickly focusing the map.

🧅

Layer Switches

Independent visibility toggles for the bus layer, train layer, and route-line layer make it easy to declutter and see only what you care about.

📱

Web · iOS · Android

One Expo codebase targets all three platforms. The same map, store, and API client run identically in the browser and on mobile.

// under the hood

Code architecture.

The project is split into an Express backend that proxies and caches the CTA feeds, and an Expo frontend (React Native for Web + native) that renders the map. The split keeps CTA API keys and request budgets on the server side, out of the app bundle.

On the backend, ctaProxy.js wraps the Bus and Train Tracker endpoints in a node-cache layer so repeated frontend requests collapse into a single upstream fetch every 15 seconds. gtfsLoader.js and the generateGeoJSON.js script bake the static GTFS feed into a compact route-shapes bundle.

On the frontend, global state lives in a Zustand store — vehicles, route shapes, and toggle state — and the map reads from it directly. A movement interpolation utility uses Turf.js to tween bus positions between polls based on heading and speed vectors.

backend/ctaProxy.js
// Cache CTA vehicle positions for 15s
const cache = new NodeCache({ stdTTL: 15 });

export async function getVehicles(mode) {
  const key = `vehicles:${mode}`;
  const hit = cache.get(key);
  if (hit) return hit;

  const { data } = await axios.get(
    endpointFor(mode),
    { params: { key: process.env.CTA_KEY, format: 'json' } }
  );

  const normalized = normalize(data, mode);
  cache.set(key, normalized);
  return normalized;
}
Project structure
📁 cta-transit-tracker/
📁 backend/
📄 server.js ← Express entry + CORS
📁 routes/
📄 api.js ← live CTA proxy
📄 staticData.js ← GeoJSON
📄 ctaProxy.js ← cached fetches
📄 gtfsLoader.js
📄 generateGeoJSON.js ← build step
📁 app/ (Expo)
📁 components/
📄 TransitMap.tsx
📄 VehicleMarker.tsx
📄 RouteLayer.tsx
📄 RouteToggleOverlay.tsx
📁 store/
📄 useTransitStore.ts ← Zustand
📁 lib/
📄 api.ts ← typed client
📄 interpolate.ts ← Turf.js
📄 app.json
📄 package.json
app/lib/interpolate.ts (excerpt)
// Dead-reckon a bus forward from its last sample
export function project(v: Vehicle, dtSec: number) {
  const speedKmh = v.speed ?? 25;
  const distKm   = (speedKmh / 3600) * dtSec;

  const from = turf.point([v.lon, v.lat]);
  const next = turf.destination(from, distKm, v.heading);

  const [lon, lat] = next.geometry.coordinates;
  return { ...v, lon, lat };
}

// stack

Built with.

Expo + RN
Cross-platform runtime. One codebase ships to Web, iOS, and Android without a custom native build.
react-native-maps
The map surface. Renders markers, polylines, and tile layers with a consistent API across platforms.
Zustand
Tiny state store for vehicles, shapes, and toggle flags. No providers, no boilerplate, just hooks.
Turf.js
Geospatial math. Handles destination points, distances, and heading-aware dead-reckoning interpolation.
Node + Express
Proxy server that hides the CTA API key, caches responses, and serves pre-built GeoJSON shapes.
node-cache
In-memory TTL cache. Collapses repeated frontend polls into a single upstream CTA fetch every 15 seconds.
TypeScript
End-to-end typing from the API client to the Zustand store to the map components.
GTFS
CTA's static schedule and shapes feed. Baked into optimized GeoJSON for efficient route rendering.

// lines

Every 'L' line.

Red Line
Blue Line
Brown Line
Green Line
Orange Line
Purple Line
Pink Line
Yellow Line

// roadmap

What's next.

// source

Open source.

The full source for both the Expo app and the Express backend is on GitHub — clone it, plug in a CTA API key, and you're tracking every bus and train in Chicago.