All projects

SPMT

A public-transit platform for Târgoviște: journey planning and a live map for riders, plus an admin console so the transit authority can edit stops, routes, and timetables.

  • React
  • Go
  • OpenStreetMap
SPMT — screenshot
SPMT

What it is

On the surface, a bus line is just a list of stops. But underneath, it is two directions, variants per direction for school days and weekends and holidays, and a shape on the map that has to follow real streets instead of drawing straight lines between pins. If any one of those things is wrong, a planner can make you wait for a bus that never comes.

The project I built has two sides. Riders get a trip planner that handles transfers, a map with every line and stop on it, departure boards, and a small corner for announcements and fares. The transit authority gets a console to edit the whole network, from stops and variants to the geometry on the map, the timetables, and the days when a given trip does not run.

How it works

The project is a Go backend, React and Postgres, plus a self-hosted OSRM instance so bus paths can follow real roads.

I wrote the routing myself around RAPTOR. Pick a date and it first works out which trips actually run that day and builds a timetable out of them. Then it searches in rounds, where each new round is allowed one more transfer than the last, and it keeps going until another transfer stops helping.

Why a second round is worth running
Why a second RAPTOR round is worth runningA time-distance chart with five stops across and time running downward as relative offsets. Two journeys are drawn from the first stop to the last. One is direct and arrives late. The other rides to an intermediate stop, waits, changes to another line, and arrives earlier despite the transfer. Neither journey is simply better than the other, so both survive.STOPSTIMEABCDE+0+15+30+45waitingROUND 2one transferROUND 1no transfers15 minearlier

Each round looks at every route touching a stop that got better in the last round. It boards the earliest trip you can still catch and carries the better arrival times downstream. Transfers cost extra minutes, so the planner never asks you to sprint between buses in 40 seconds. It also refuses to put you back on the route pattern you just stepped off.

I also check a few nearby departure times around what the user asked for. Results get cached in memory by date and origin-destination pair.

The admin console has an AI assistant that can look things up and propose edits, but I didn’t want it writing to the database on its own. So reads run right away, while writes pile up in a pending plan instead. Each proposed change carries a snapshot of what was there before, and nothing gets applied until a person approves the whole plan.

Where it got hard

Divided streets were the most challenging, the kind with one lane each way and a concrete median down the middle. OSRM kept snapping stops onto the wrong side of the road and drawing little U-turn loops in the middle of routes.

My first instinct was to shrink the snap radius. That made it worse. Stop pins are almost never exactly on the asphalt, so a tight radius just left stops unmatched or yanked them somewhere stranger. What finally worked was telling OSRM which way the bus is moving. Every stop gets the compass heading from the stop before it, and the bearings parameter keeps the approach within 90 degrees of that heading.

backend/internal/service/geometry_service.goprivate repo
bearings := make([]string, len(stops))
bearings[0] = ""
for i := 1; i < len(stops); i++ {
	heading := bearingBetween(stops[i-1], stops[i]) // degrees clockwise from north
	bearings[i] = fmt.Sprintf("%d,90", int(math.Round(heading)))
}
query.Set("bearings", strings.Join(bearings, ";"))
// Example: ";217,90;225,90;198,90"
One heading per stop, computed from the stop before it. The first stop has nothing before it, so it stays unconstrained.

What I'd change

The timetable cache still lives in the server process. With a single instance that holds up fine, except the first search after a deploy has to rebuild the whole day before it can answer. Precomputing it at startup would reduce that delay.