Routing, maps and geocoding
Everything on the rounds page is open source and free to run. Nothing here bills per request.
The map
Section titled “The map”MapLibre GL draws it, on vector tiles from OpenFreeMap: free, no key, no quota, OpenStreetMap data. The base style is restyled in the browser to Bottle’s palette, by layer type and name, so it survives the style changing underneath it.
MapLibre parses tiles in a web worker, which the web app serves from
public/maplibre/, copied out of node_modules by scripts/copy-map-worker.mjs
before dev and build. Left to its own devices the library builds the
worker as a blob importing a module relative to its bundle, which under Next’s
bundler resolves to nothing, and the map sits grey with no error. If the map
ever does that again, check the worker was copied.
The credit behind the ⓘ in the corner reads “© OpenStreetMap contributors, © OpenMapTiles” and stays. OpenStreetMap data is under the ODbL and OpenMapTiles under CC-BY, and showing that credit is a condition of using either, not a courtesy. OpenFreeMap asks for no credit of its own, so its name is not on the map. Do not remove the control.
To serve tiles yourself, point NEXT_PUBLIC_MAP_STYLE_URL at any MapLibre
style JSON. A UK extract as a single
PMTiles file on a static host is a few
gigabytes and needs no server.
Geocoding
Section titled “Geocoding”Every address, depot and one-off order is placed from its postcode by
postcodes.io: open source, ONS data, free at
https://api.postcodes.io, and a Docker image if you would rather not depend
on theirs. Set GEOCODER_URL to your own instance, or to empty to turn
geocoding off along with the map.
A postcode is enough to put a vehicle within a street. The lookup also returns the district, county and region, which is what a driver’s patch is matched against, so nobody types those in.
Addresses are placed as they are saved. The nightly job places anything that was missed, such as addresses from before there was a map.
Ordering the stops
Section titled “Ordering the stops”The order of a round’s stops is a travelling salesman between a fixed start and a fixed end. Bottle solves it the way a good dispatcher does by eye: nearest neighbour from the start, then 2-opt until no two legs cross. Tens of stops, milliseconds, within a few percent of optimal on real geography.
What it needs is the distance between every pair of points, and that comes from one of two places.
Straight lines, the default. Nothing to install. Distances are as the crow flies, times assume a vehicle in town, and every round made this way says so.
OSRM, the Open Source Routing Machine, for
roads. Set OSRM_URL and the same page gives real distances, real times, and
a route drawn along the streets. If OSRM is unreachable Bottle falls back to
straight lines for that round and logs it.
Which one is in use is said at startup: routing on roads with the address,
or a warning that there is no OSRM_URL. Worth having, because the two give
very different numbers and “no warning in the log” is a poor way to find out
which you are getting.
See Hosting for preparing the map and running the engine. On a test box it is one container reading prepared files from disk, and Great Britain costs about 2 GB of memory to serve.
Running OSRM
Section titled “Running OSRM”A UK extract is about 1.5 GB of OpenStreetMap data and needs roughly 8 GB of memory to prepare, less to serve.
mkdir osrm && cd osrmcurl -O https://download.geofabrik.de/europe/united-kingdom-latest.osm.pbfdocker run -t -v "$PWD:/data" ghcr.io/project-osrm/osrm-backend osrm-extract -p /opt/car.lua /data/united-kingdom-latest.osm.pbfdocker run -t -v "$PWD:/data" ghcr.io/project-osrm/osrm-backend osrm-partition /data/united-kingdom-latest.osrmdocker run -t -v "$PWD:/data" ghcr.io/project-osrm/osrm-backend osrm-customize /data/united-kingdom-latest.osrmdocker run -t -i -p 5000:5000 -v "$PWD:/data" ghcr.io/project-osrm/osrm-backend osrm-routed --algorithm mld /data/united-kingdom-latest.osrmThen OSRM_URL=http://localhost:5000. Rebuild the extract every few months;
roads change slowly.
Do not point production at router.project-osrm.org. It is a demo, and
promises nothing.
Getting as close to the door as possible
Section titled “Getting as close to the door as possible”Two things decide where a van actually stops.
The point we ask for. By default an address is geocoded from its postcode,
which is a centroid. Where somebody has dropped a pin on the address, that pin
is used instead, for the matrix, the route and every arrival time. One helper,
drivingPointOf, decides which, so nothing in the app can disagree about
where a door is.
Where the router snaps it to. OSRM’s car profile never routes along a
footway, an alley or a footbridge, so the driving is road-only already. What
it does do is snap the point we give it to the nearest road as the crow flies,
which can be the far side of a barrier. Requests therefore go out with
approaches=curb on the doors, so the van arrives on the near kerb rather
than across a dual carriageway, and unrestricted at either end, because a
depot is not a kerb and pinning it to one can make the whole route
unanswerable.
Some doors cannot be reached kerbside at all: a one-way street, a service road
the wrong way round. OSRM answers NoRoute rather than choosing for us, so
the question is asked again without the restriction. A longer walk beats no
round.
Hours are part of the problem
Section titled “Hours are part of the problem”Where a door has delivery hours, the order is chosen against the clock rather than checked against it afterwards. The solver drives each candidate order on paper — the same arithmetic the planner uses to write the times down — and judges it on two things in order: how many doors it misses, then how long the day is. A missed door beats every other consideration, because a drop nobody can make is a wasted trip, a second visit and a customer who waited in, and no amount of saved driving pays for one.
The greedy start takes the door that shuts soonest rather than the nearest, which gives the improvement passes something feasible to polish. Two passes then run until neither finds anything: 2-opt, which uncrosses legs and stops a route doubling back, and or-opt, which lifts one drop out and puts it somewhere else. Or-opt is the one that matters here. Moving a single awkward door to where its window is, leaving everything else alone, is exactly what a dispatcher does by hand.
A day that genuinely cannot be driven still gets a round, with the doors it could not reach marked. The office would rather see it and decide than be handed nothing.
The solver and the planner share one walk (walkRound), on purpose. Two
pieces of arithmetic is how a round gets planned to hit a window and then
reports that it missed it.
Traffic
Section titled “Traffic”A router answers in free-flow speeds: the limit on the sign, with nothing in the way. OSRM reads those off OpenStreetMap and has no idea what time it is. Nobody drives in free flow, and in London nobody comes close, so every driving time is padded before it is used.
Two numbers, under Settings › Business › How a day is reckoned:
- Add to driving times, a percentage across the day. 15% to start with.
- Add during the rush, used instead on the legs actually driven inside the two rush windows. 40% to start with, for 07:00–09:30 and 16:00–18:30. Clear both times of a window and that rush is off, which is right for a yard whose roads do not have one.
The allowance is applied leg by leg, at the moment each leg sets off, so the morning rush lands on the legs driven in it rather than being smeared across the day. Every wait at a closed door pushes the following legs later, and they are padded by whatever the road is doing then.
It applies in one place, so the number on a driver’s phone, the window in a customer’s email and the hours a round is judged against cannot disagree. The straight-line stand-in is free-flow too for the same reason: traffic is modelled once, not twice.
Rounds already planned keep the times they were planned with until something measures them again. Changing the allowance does not quietly rewrite yesterday.
Getting the numbers right
Section titled “Getting the numbers right”Start with the defaults, then watch rounds come back. Consistently early means the allowance is too high; consistently late means too low. A yard in Cornwall and one in Hackney want very different figures and neither is wrong.
Two better sources exist, neither built yet:
- A segment speed file. OSRM’s
osrm-customize --segment-speed-filetakes real speeds per road segment and applies in minutes without re-extracting, which suits the MLD setup indeploy/. It needs a speed source: a commercial feed, or the one below. - Your own rounds. Bottle records where a van was every few minutes while it is out, and when each stop was completed. Predicted leg against actual leg, by hour of day, is a per-business allowance that nobody has to guess, and it beats a generic feed because it includes your vans and your doors.
Adding the day up
Section titled “Adding the day up”Driving is only part of a round. Measuring one adds the rest from the
business’s planning settings (GET /planning): loadMinutes at the yard
before setting off, stopMinutes at every door, itemMinutes for each item
on the order, and the driver’s break. Each stop’s etaSeconds is the drive
to it plus every door before it; etaAt is that as a time of day, hung off
the round’s departsAt, which is the office’s if set, else the driver’s
shiftStart, else the business’s dayStart.
Load is added up from the products: weightKg and volumeLitres per item,
against the vehicle’s payloadKg and volumeLitres. Items whose product has
neither are counted in unweighedItems rather than guessed.
The result is fit on every round: load against payload, litres against
space, backAt against shiftEnd, and over, the list of limits broken.
Every limit is nullable and a null limit is simply not applied. The maths is
fitRound in @bottle/schemas, pure, so the planner and the card give the
same answer.
Trimming
Section titled “Trimming”POST /rounds/plan-day trims each round it makes until over is empty: over
hours, the stop with the largest detour by the router’s matrix; over weight,
the heaviest; over space, the bulkiest. One at a time, measured again between.
What came off is in waiting, each with a reason from hours, weight,
volume, noPatch or unplaced. Nothing in this knows how a distance is
found: the router is asked for matrices and legs as before, so roads slot in
when OSRM is set up.
Proof, and telling the customer
Section titled “Proof, and telling the customer”PUT /rounds/{id}/stops/{stopId} takes the outcome at a door. A delivered
outcome must carry a proof: either a signature with signedBy and a PNG
data URL, or a left_safe with a JPEG of where it was put. The API refuses a
delivery without one, and undoing a delivery deletes the proof with it.
Images are base64 in delivery_proofs for now, with the browser downscaling a
photograph to 1280px before it is sent. That is not where this ends up: a
photograph belongs in object storage with a key in the row. It is one column
to move when there is a bucket, which is cheaper than half an abstraction
built before there is one.
Each proof gets a random token, and GET /proofs/{token} serves the image
with no session at all. The person it is for is a customer who has never
signed in and never will, so the token is the whole of the permission, the
same bargain as an invitation link.
Two customer emails hang off this. Setting a round out sends everyone on it
a two-hour window, once, stamped on the stop so a flaky signal does not send
it twice. Recording a delivery sends that customer the delivered email, with
the photograph’s link where there is one. Neither throws: a delivery that
happened is a delivery that happened, and an SMTP server having a bad
afternoon must not make a driver’s screen say otherwise.
A stop is a visit
Section titled “A stop is a visit”round_stops used to carry unique(order_id): an order could be on one
round, ever. That quietly made a second attempt impossible, which nobody
noticed until deliveries could be partial.
It is now unique(order_id) where completed_at is null. A stop records one
visit and stays as history; only an unfinished one claims the order. Marking a
door answered, either way, sets completedAt and frees the order to be
planned again.
What actually came off the van is in round_stop_items, one row per line,
written only where the answer was not “all of it”. How much a customer has had
altogether is the sum of those rows rather than a counter kept on the line,
which cannot drift.
A shortfall carries a shortfall reason, and shortfallOutcome in
@bottle/schemas says what it does: not_on_vehicle and damaged leave the
rest owed; refused returns it on the spot, adding cancelledQuantity to
each line and pricing it onto orders.refundDue; other leaves it for the
office. POST /orders/{id}/return-remainder is the office doing the same by hand,
and POST /orders/{id}/refund-settled records that the money has gone. A
stop item remembers how many it wrote off so that undoing the visit puts back
exactly that many.
An order closes when every line is delivered or returned. refundDue is
recorded, never paid: the accounting integration raises the credit note.
Through the API
Section titled “Through the API”rounds:read and rounds:write cover locations, rounds and the planning
settings. drivers:read and drivers:write cover which vehicle a driver
takes and their hours. A driver’s own session holds rounds:read, narrowed
by the API to their rounds.
Every round carries routedBy, osrm or straight-line, so a client can
tell a guess from a road route. geometry is an encoded polyline, precision
5, when a road router drew one.