ts-maps

Services

On this page 13

ts-maps includes a small adapter layer for external geo services. The adapters target a common interface so you can swap providers without rewriting your call sites. Defaults are all keyless and open-source.

Provider matrix

CapabilityDefaultAlternatives
GeocodingNominatimGeocoderGazetteerGeocoder (self-hosted), PhotonGeocoder, MapboxGeocoder, MaptilerGeocoder, GoogleGeocoder
DirectionsOSRMDirectionsValhallaDirections, MapboxDirections, GoogleDirections
IsochronesValhallaIsochroneMapboxIsochrone
Elevation—ValhallaElevation
MatrixValhallaMatrixMapboxMatrix

Geocoding

import { services } from 'ts-maps'

const geocoder = services.defaultGeocoder() // NominatimGeocoder

const results = await geocoder.search('Tower Bridge, London')
// → [{ text, center: { lat, lng }, bbox, placeType, properties }, …]

const reverse = await geocoder.reverse({ lat: 51.5055, lng: -0.0754 })

For Apple Maps–style search — suggestions from the map itself as you type, Find Nearby, result pins and place cards — see the search control, which merges any provider with what is on the map and in downloaded offline maps.

The public geocoders are shared services with usage policies — Nominatim's forbids search-as-you-type outright. ts-maps/gazetteer runs place search on your own server instead: GeoNames populated places in SQLite with a full-text index, ranked by name match, population and nearness. cities1000 (every place of 1,000+ people, 170k) builds in a few seconds into a 60 MB file and answers in about a millisecond.

// server (Bun)
import { buildGazetteerFile, createGazetteerHandler, downloadGeoNames, Gazetteer } from 'ts-maps/gazetteer'

buildGazetteerFile('places.sqlite', await downloadGeoNames({ dataset: 'cities1000' }))
const handle = createGazetteerHandler(new Gazetteer('places.sqlite'), { basePath: '/geo' })
Bun.serve({ fetch: async req => (await handle(req)) ?? new Response('Not found', { status: 404 }) })
// browser
import { GazetteerGeocoder } from 'ts-maps/services'

const geocoder = new GazetteerGeocoder({ baseUrl: '/geo' })
await geocoder.search('Portland, ME') // Portland, Maine, United States
await geocoder.search('st george')    // Saint George, Utah — abbreviations match
await geocoder.search('Munich')       // München, Bavaria, Germany — any alternate name

Text after a comma qualifies the place (San Diego, TX, Paris, France); proximity, countries and bbox narrow it further. Gazetteer itself implements GeocoderProvider, so server code can call search / reverse directly. The GeoNames licence (CC BY 4.0) asks for credit — GEONAMES_ATTRIBUTION holds the line to show.

Handing off to a navigation app

Turn-by-turn belongs in the app the person already drives with. directionsLinks builds https links that open Apple Maps, and the Google Maps app when it is installed:

import { directionsLinks } from 'ts-maps/services'

const { apple, google } = directionsLinks({ lat: 32.8894, lng: -117.2519 }, { mode: 'driving' })
// apple:  https://maps.apple.com/?daddr=32.8894%2C-117.2519&dirflg=d
// google: https://www.google.com/maps/dir/?api=1&destination=32.8894%2C-117.2519&travelmode=driving

Modes are driving (default), walking, cycling and transit; pass origin to start somewhere other than the device's location. appleMapsDirectionsUrl and googleMapsDirectionsUrl build one link each.

Drawing a route

RouteBuilder is the logic behind "tap the map to draw a route", with no UI attached: waypoints, the line between each pair (along real paths through a router, or straight), undo, closing the loop and out-and-back.

import { climb, directionsRouter, RouteBuilder, resamplePath, ValhallaDirections, ValhallaElevation } from 'ts-maps/services'

const builder = new RouteBuilder({ router: directionsRouter(new ValhallaDirections(), 'walking') })
builder.onChange(() => line.setLatLngs(builder.path.map(p => [p.lat, p.lng])))
map.on('click', e => builder.add(e.latlng))

await builder.closeLoop()      // or builder.outAndBack()
await builder.undo()
builder.distanceMeters         // along the drawn line
builder.isLoop                 // ends within 50 m

const heights = await new ValhallaElevation().getElevations(resamplePath(builder.path, 200))
climb(heights)                 // { gain, loss } in metres, DEM noise filtered out

Taps made while a segment is still routing are queued, so fast tapping draws in order. A segment the router cannot do (no path, offline, rate-limited) is drawn straight and lastError says why — pass fallbackToStraight: false to refuse it instead. straightRouter draws every segment straight; setRouter switches between the two mid-route. load(path) starts from an existing line, such as a catalog trail.

Directions

import { services } from 'ts-maps'

const d = services.defaultDirections() // OSRMDirections

const routes = await d.getDirections([
  { lat: 51.5055, lng: -0.0754 },
  { lat: 51.5074, lng: -0.1278 },
], { profile: 'driving', overview: 'full' })

// routes[0] = { distance, duration, geometry: LatLng[], legs, steps }

In traffic

Where a provider knows today's traffic, routes say so: duration is the time in traffic, typicalDuration the time on a clear road, and traffic is true. The turn-by-turn preview shows the difference, "4 min delay", orange as it grows and red when it is heavy, or "Light traffic".

new MapboxDirections({ accessToken, traffic: true }) // the driving-traffic profile
new GoogleDirections({ apiKey, traffic: true })      // leaving now, duration_in_traffic

OSRM and Valhalla's public servers have no live traffic; a Valhalla of your own with a traffic feed answers in traffic without being asked.

By transit

Walk, ride, change, walk, as Apple plans it. Transit needs timetables, so it comes from a provider that has them:

import { OpenTripPlannerDirections } from 'ts-maps/services'

const transit = new OpenTripPlannerDirections({ url: 'https://otp.example.com/otp/gtfs/v1' })
const [route] = await transit.getDirections([from, to], { profile: 'transit', departAt: new Date() })

route.steps.map(s => s.instruction)
// ['Walk to Embarcadero Station', 'Take the N Judah toward Ocean Beach, 6 stops', 'Walk to your destination']
route.steps[1].transit // { vehicle: 'tram', line: 'N', lineName: 'Judah', color: '#005B95', headsign, stops, departure, arrival, … }

OpenTripPlanner is open source and plans from GTFS feeds and OpenStreetMap; run your own, or use an agency's or a region's public instance (Entur, Digitransit). GoogleDirections plans transit too, with profile: 'transit'. departAt and arriveBy choose the time. Providers without transit (OSRM, Valhalla, Mapbox, offline routing) say so plainly rather than answering by car.

TurnByTurn with profile: 'transit' previews each route as its rides, the line names on their colours, with when it leaves and arrives, and speaks each step in its own words. styles.transit({ tiles }) is the Transit map: streets quietened, rail and tram lines drawn strong, stations named; mapTypes() offers it in the picker.

Encoded polylines, as these providers send shapes, are decodePolyline(encoded, precision) and encodePolyline(points, precision).

Turn-by-turn navigation

turnByTurn(map) is navigation after Apple Maps: route options, then guidance with a banner, a voice, and a camera that follows from behind.

import { turnByTurn } from 'ts-maps'

const nav = turnByTurn(map, { destinationName: 'Ferry Building' })
await nav.preview(from, to)   // routes drawn, card with each option and Go
nav.start()                   // or the user taps Go

Preview draws every route the provider offers — the chosen one in blue, alternatives in grey, all tappable — frames them, and shows a card with the time and distance of each.

Guidance shows:

  • a banner with the next maneuver's arrow, the distance to it and the road it leads onto ("400 ft · Market St"), and a Then row when a second maneuver follows closely;
  • a card with arrival time, minutes and distance left, mute, and End;
  • the route ahead in blue and the road already driven in grey;
  • a camera that follows heading-up from behind and above, closer in at low speed and pulled back at speed, gliding between GPS fixes rather than jumping once a second — pan it and a Resume button brings it back.

Lane guidance appears under the banner as a maneuver draws near — within 800 m driving, 250 m cycling — when the road has lanes and a choice to make between them: every lane approaching the maneuver, the ones to be in bright with the arrow they use, the rest dimmed. The early and get-ready prompts say which to be in: "In 400 feet, use the left 2 lanes to turn left onto Broadway". Lanes come from OSRM and Mapbox, which describe them; laneHint, laneIcon and lanesMatter in services word and draw them for your own UI.

Spoken prompts come early, to get ready, and at the turn ("In a quarter mile, turn right onto Market Street"), each once. A few seconds off the route fetches a new one from where you are; arriving says so.

Positions come from the Geolocation API. To try it at a desk, simulate: true drives the route instead, slowing for turns — or pass { speed, timeScale }. Feed positions from anywhere else with nav.update(fix).

OptionDefault
directionsOSRMAny DirectionsProvider
profile'driving''walking' and 'cycling' change the camera and prompt distances
unitsfrom the locale'metric' or 'imperial'
voicetrueSpeech synthesis, where the browser has it
simulate—Drive the route instead of following the device
alternativestrueOffer alternative routes in preview

Events: preview, routeselect, start, progress, instruction, reroute, arrive, end, error.

The guidance itself has no map or DOM in it. services.Navigator takes a route and positions and produces progress, instructions, off-route and arrival events; services.formatInstruction, formatDistance and maneuverIcon word and draw a maneuver from any provider, whose maneuver codes are folded into one vocabulary by parseManeuver. Steps now carry the road's name and a roundabout's exit where the provider gives them.

With no connection

Downloaded offline maps carry their own place index and road network: map.offline.geocoder() and map.offline.directions() are providers like any other, and withOfflineFallback(online, offline) puts one behind an online provider. See Offline maps.

Isochrones

import { services } from 'ts-maps'

const iso = services.defaultIsochrone() // ValhallaIsochrone

const polys = await iso.getIsochrones(
  { lat: 51.5074, lng: -0.1278 },
  { contours: [5, 10, 15], profile: 'walking' }, // minutes
)

Matrix

const mtx = services.defaultMatrix()
const { durations, distances } = await mtx.getMatrix(origins, destinations, { profile: 'driving' })

Writing a custom provider

Any object implementing GeocoderProvider (or the corresponding interface for directions / isochrones / matrix) can be passed anywhere a default provider is expected. See services/types.ts in the source for the contracts.