Skip to main content

Documentation

Route Planning & Geocoding

Multi-stop ordering, maps deep links, and forward geocoding from maps-core

Tags: maps routing geocoding utility

Two subpath exports of @classic-homes/maps-core. Both are framework-free: they import no maplibre-gl, touch no DOM and have no runtime dependencies, so the same code runs in a browser, in a Cloudflare Worker and under Vitest.

import { optimizeOrder, googleDirectionsUrl } from '@classic-homes/maps-core/route';
import { geocodeForward } from '@classic-homes/maps-core/geocoding';
Warning

Route distances are crow-flies, not driving distances. They give a sensible visit order, not an ETA. Label anything you show a user as approximate.

Coordinates: LatLng, not LngLat​

Route planning uses an object, { lat, lng } — deliberately not the [lng, lat] tuple MapLibre and the rest of maps-core use. A silently transposed tuple produces a plausible-looking route on the wrong side of the planet; named fields make the mistake unrepresentable. Convert at the map boundary.

import { toLngLat, fromLngLat, isValidLatLng } from '@classic-homes/maps-core/route';

const stops = rows.filter((row) => isValidLatLng(row.coords));
map.setCenter(toLngLat(stops[0].coords));
const clicked = fromLngLat(event.lngLat.toArray());

isValidLatLng rejects null, undefined, numeric strings and out-of-range values. Filter your rows through it: an ungeocoded stop otherwise reaches the URL builders, which return '' for it rather than emitting a link to nowhere.

Ordering stops​

import { optimizeOrder } from '@classic-homes/maps-core/route';

const { order, legMiles, totalMiles } = optimizeOrder(userLocation, stops);
const itinerary = order.map((i) => stops[i]);
Function Use it for
optimizeOrder(origin, points, opts?) Nearest-neighbour then 2-opt. What most callers want.
nearestNeighborOrder(origin, points) Greedy only — deterministic, ties break on lower index.
twoOptImprove(origin, points, order, matrix, opts?) Improve an order the user already has.
measureOrder(origin, points, order) Leg distances for an order the user chose; no reordering.
haversineMiles(a, b) / haversineKm(a, b) One distance. Returns NaN for non-finite input.
distanceMatrix(points) Symmetric matrix, zero diagonal.

OrderResult.legMiles[0] is origin → first stop, so legMiles[i] is the leg arriving at order[i]. Stops with unusable coordinates are appended in input order rather than dropped, so order is always a permutation of every index you passed.

The 2-opt pass is the open-path formulation: there is no return edge to the origin, because a tour ends at the last stop. It is a local search — it removes crossings, it does not promise the globally shortest route.

import { googleDirectionsUrl, appleDirectionsUrl } from '@classic-homes/maps-core/route';

const google = googleDirectionsUrl(stops, userLocation, 'driving');
const apple = appleDirectionsUrl(stops, userLocation);

A target is { coords?, address? }. When both are present the coordinate wins — it cannot be misgeocoded. A target with neither is skipped, and a call with nothing usable returns '', which is your cue to hide the button.

Note

With more than one stop, appleDirectionsUrl emits Apple's unified Maps URL — https://maps.apple.com/directions with the last stop as destination and the rest as repeated waypoint parameters. That scheme needs iOS 18.4 / macOS 15.4 or later; older versions predate it. A single stop keeps the legacy saddr/daddr form, which every version opens.

Google renders at most 9 intermediate waypoints. For longer routes use googleDirectionsLegUrls, which chunks into consecutive legs — each leg starting where the previous one ended — instead of dropping stops:

const legs = googleDirectionsLegUrls(stops, userLocation, 'driving');
// legs[0] covers stops 1-10, legs[1] resumes at stop 10, and so on

Forward geocoding​

The API key and fetch are both injected, so the key can stay server-side. A key compiled into a client bundle is a public key, and geocoding is billed per request — hold it in a Worker or route handler and expose your own endpoint.

import { geocodeForward } from '@classic-homes/maps-core/geocoding';

const match = await geocodeForward(
  address,
  {
    key: env.MAPTILER_KEY,
    proximity: { lat: 38.8339, lng: -104.8214 }, // the single best relevance knob
    country: 'us',
    signal: AbortSignal.timeout(5000),
    onError: (err) => console.error('geocode', err),
  },
  fetch
);

if (match) map.setCenter(toLngLat(match.coords));

geocodeForward resolves null on every failure — a miss, a rejected key, a rate limit, an abort, a dead network — so an address box never blows up the page it sits on. Those are very different operationally, so pass onError in anything you have to debug in production. Pass signal too: with no abort path a hung provider holds a Worker invocation open until the platform kills it.

For a picker that shows several candidates, raise limit and use geocodeForwardMany, which returns every usable match best-first. buildMapTilerGeocodeUrl and parseMapTilerGeocodeResults are exported separately so you can put your own cache or retry layer between the request and the parse.

On this page