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';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.
Deep links
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.
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 onForward 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.