Build a nearest-location finder
"Closest" has two meanings and they disagree often enough to matter. The nearest branch as the crow flies can be across a river with the bridge eight kilometres upstream. This finds candidates by distance, then ranks the shortlist by actual drive time, and draws the route to the winner.
The code is plain TypeScript. For the React, Next.js, Vue or Nuxt wiring, start from the framework recipes.
Find the customer, then the candidates
nearby sorts by real-world distance from an anchor and carries a distance in metres on each
result. Take more candidates than you need: the shortlist is what you are about to rank properly.
const [customer] = await unmap.geocoder.search("Bow Valley Square, Calgary", { limit: 1 });
if (!customer) throw new Error("customer address not found");
const candidates = await unmap.geocoder.nearby("shop.convenience", {
near: [customer.lng, customer.lat],
radius: 5000,
limit: 5,
});radius is metres, defaults to 5000, and is clamped to between 50 and 50000. If you are matching
against your own branches rather than a category from the corpus, skip this call and use your own
list: the ranking step below is the part that matters.
Rank the shortlist by drive time
One route request per candidate, in parallel, then take the smallest duration. Five candidates is five metered calls, which is the honest cost of the difference between straight-line and real distance.
import type { GeocodeResult } from "@unmap/geocoding";
async function nearestByDriveTime(from: [number, number], candidates: GeocodeResult[]) {
const legs = await Promise.all(
candidates.map(async (candidate) => ({
candidate,
route: await unmap.router.route(from, [candidate.lng, candidate.lat], { mode: "auto" }),
})),
);
return legs.sort((a, b) => a.route.durationSeconds - b.route.durationSeconds)[0];
}Keep the shortlist short. This is the one place a nearest-location finder gets expensive, and routing every branch you own rather than the five nearest is how a page ends up making forty calls to answer one question.
Draw the way there
geometry is a GeoJSON LineString, so it becomes a source with no translation step.
import type { RouteResult } from "@unmap/routing";
function drawRoute(route: RouteResult) {
unmap.map!.addSource("leg", {
type: "geojson",
data: { type: "Feature", properties: {}, geometry: route.geometry },
});
unmap.map!.addLayer({
id: "leg-line",
type: "line",
source: "leg",
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": "#FF3E9A", "line-width": 3 },
});
}durationSeconds and distanceMeters come back on the same result, so the label beside the
branch ("12 min, 6.4 km") needs no second call.
Where this gets the wrong answer
If nearby finds no candidate within the radius, the correct behaviour is to widen it once and
then tell the customer, not to keep widening until something matches. A branch 40 km away is a
true answer to the wrong question.
The category has to exist in the corpus. shop.convenience is a category id from the
geocoding docs; a word that is not one returns nothing rather than guessing.
And coverage stops at the border, so a customer in Windsor will not be matched to a branch in Detroit even when that is genuinely the nearest one.
Related
- Find nearby places for the smallest possible version
- Get directions for the routing call on its own
- Geocoding API for the category list and the four endpoints