OOOlorede.
Back to Projects
Full-StackFeaturedJanuary 2025

Busly

Stop-centric transit navigation for informal urban networks, built for Lagos.

React 19TypeScriptViteTailwind CSS 4Framer MotionLeafletZustandReact QueryRadix UIReact Router 7Express.js 5MongoDB AtlasMongooseJWTSwagger / OpenAPI
Busly
Overview

Busly solves a navigation problem that Google Maps ignores: the informal, cash-based, route-flexible bus networks (Danfo, BRT, Keke) that move the majority of commuters in Lagos. Formal mapping tools assume fixed schedules and named streets — neither exists here. Busly models the city as a directed, weighted stop graph loaded into server memory at startup. A custom A* algorithm with a Haversine heuristic finds the optimal path across this graph, composing multi-leg journeys that may span several routes connected by walking transfers. Each stop in the returned path is enriched with the nearest landmark within 300m and a crowdsourced navigation cue, because landmark-based wayfinding is how real commuters navigate.

Architecture

On startup, the backend connects to MongoDB Atlas and loads all stops (nodes), active route stop sequences (directed ROUTE edges weighted by averageTravelTimeToNext in seconds), and transfer records (bidirectional TRANSFER edges with a walking penalty plus a 120-second boarding buffer) into a Map<string, GraphNode> adjacency list held in process memory — making all A* traversals pure in-memory operations with no DB round-trips on the hot path. For a journey search, the service snaps the user's coordinates to the 3 nearest transit stops via MongoDB's $geoNear aggregation, then runs two strategies in parallel: a direct DB query for single-route trips and an in-memory A* search with a Haversine heuristic. Results are merged, deduplicated, and sorted by total duration. Each stop in the path is enriched with the nearest landmark within 300m via a $near geospatial query. On the frontend, React Query manages all server state, a Zustand store holds the active journey session, and Framer Motion handles page transitions and journey progress animation.

Technical Decisions
01

Graph Performance

Chose

In-memory routing graph (singleton loaded at startup) instead of per-request MongoDB graph queries

Why

MongoDB's aggregation pipeline is too slow for multi-hop graph traversal at request time. A* on a transit network with ~270 stops and ~700 edges needs sub-50ms traversal. Materializing the entire graph into a Map<string, GraphNode> at startup achieves this since all traversal is pure JavaScript object lookups.

Trade-off

The graph is a point-in-time snapshot. Any change to stops, routes, or transfers in the DB requires a manual reloadGraphFromDB() call or server restart to take effect.

02

Route Search Strategy

Chose

Dual-strategy routing: DB direct-route query first, A* fallback for multi-transfer paths

Why

The majority of journeys in a city transit network are single-route trips. A MongoDB query that checks whether both stops appear in the same stopsSequence array is cheaper and returns a more semantically clean result than unwinding an A* path for the same single-leg trip.

Trade-off

Two code paths must be maintained and their results merged. Deduplication by route-leg fingerprint is necessary but could miss logically equivalent paths with different route segment orderings.

03

Pathfinding Algorithm

Chose

A* with Haversine heuristic (distance / 5m/s) instead of BFS or Dijkstra

Why

Dijkstra explores nodes uniformly by cost, which is wasteful in a geographically embedded graph where the destination's rough direction is known. The Haversine straight-line distance divided by a conservative 5m/s base speed is an admissible heuristic, guaranteeing optimal paths while dramatically pruning the open set.

Trade-off

The 5m/s constant is a simplification. In practice, transfers have walking speeds closer to 1–1.5m/s and buses operate at 6–10m/s in Lagos traffic.

04

Landmark Enrichment

Chose

Landmark injection via per-stop $near query rather than pre-joining on graph load

Why

Landmarks are crowdsourced and change frequently. Pre-joining them into the graph at startup would mean landmark updates also require graph reloads. Keeping landmark enrichment as a live per-stop DB query ensures fresh cues without coupling landmark writes to graph lifecycle.

Trade-off

For a path with N stops, this fires N sequential $near queries. Acceptable at low concurrency but would benefit from a batched geospatial lookup or landmark-proximity cache under load.

05

State Management

Chose

Zustand for active journey session state, React Query for all server state

Why

Journey session state (current stop index, selected route, deviation flag) is purely client-side ephemeral state with no server equivalent — Zustand's minimal API is the right fit. React Query handles all async server state with automatic background refetching, caching, and stale-while-revalidate.

Trade-off

Two state libraries must coexist. The boundary between what lives in Zustand vs the React Query cache must be consciously maintained, especially for derived data like the active stop's landmark details.

Future Improvements

Replace per-stop sequential $near landmark queries with a single batched geospatial lookup or a landmark proximity cache (TTL ~1 hour) to prevent O(N) DB queries per journey response under load

Add a reloadGraph admin endpoint triggered by MongoDB change streams on Stop, Route, and Transfer collections, so live data changes propagate to the in-memory graph without a server restart

Implement a Redis OD-pair cache keyed by originStopId:destStopId to serve repeat corridor queries (e.g. CMS → Ajah) without re-running A*

Replace the A* open set's O(N) minimum scan with a binary min-heap priority queue to improve worst-case routing performance on denser graph expansions

Add WebSocket push for trip session updates, replacing the current polling pattern for currentStop progression and deviation detection