<bmx-route-planner>
Plans a day's deliveries or visits: depots, stops with what they take and when they may be served (time windows), and vehicles with their capacities, shifts, breaks and skills. The optimiser - savings construction and local search, on a background thread - finds routes that serve the stops at the lowest cost within every limit; the routes are drawn on a map (over the customer's own GeoJSON, when given), each vehicle has its schedule, and a stop can be dragged from one route to another, on the map or in the list, with the change in distance and cost shown as it is dragged. Export the plan as JSON, the stops and the routes as CSV, and printable driver sheets.
13 properties · 4 events · 16 methods · 4 parts
Example
Try it with your own stops in confidence: the plan is kept in your browser, on your device. Nothing is uploaded, no copy reaches us or anyone else, and nothing is kept once you close this tab unless you export it.
Press Optimise, then drag a stop from one route to another - on the map or in the list - and watch the cost change.
Show markup
<bmx-route-planner id="ex-route" currency="£" style="--bmx-route-planner-height: 38rem"></bmx-route-planner>
<p class="note" id="ex-route-out" role="status" style="margin: 12px 0 0">Press Optimise, then drag a stop from one route to another - on the map or in the list - and watch the cost change.</p>
<script type="module">
await customElements.whenDefined('bmx-route-planner');
const planner = document.getElementById('ex-route');
const out = document.getElementById('ex-route-out');
// An invented town, Fenwick Market, with a made-up map: a river, a ring road, a few streets and a park. It is
// placed out at sea so it cannot be mistaken for anywhere real. Every name, address and number is made up.
const lon = x => 0.02 + x * 0.0009;
const lat = y => 50.02 + y * 0.0006;
const P = (x, y) => [lon(x), lat(y)];
const ring = [];
for (let a = 0; a <= 360; a += 15) ring.push(P(50 + 38 * Math.cos((a * Math.PI) / 180), 50 + 40 * Math.sin((a * Math.PI) / 180)));
const streets = [];
for (let i = 20; i <= 80; i += 12) streets.push([P(i, 14), P(i + 4, 86)], [P(14, i), P(86, i - 4)]);
planner.basemap = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { kind: 'land' }, geometry: { type: 'Polygon', coordinates: [[P(-10, -10), P(110, -10), P(110, 110), P(-10, 110), P(-10, -10)]] } },
{ type: 'Feature', properties: { landuse: 'residential' }, geometry: { type: 'Polygon', coordinates: [[P(30, 30), P(70, 28), P(72, 70), P(28, 72), P(30, 30)]] } },
{ type: 'Feature', properties: { leisure: 'park', name: 'Abbey Park' }, geometry: { type: 'Polygon', coordinates: [[P(58, 58), P(70, 57), P(71, 68), P(59, 69), P(58, 58)]] } },
{ type: 'Feature', properties: { natural: 'water' }, geometry: { type: 'Polygon', coordinates: [[P(-10, 44), P(20, 46), P(50, 41), P(80, 47), P(110, 43), P(110, 47), P(80, 51), P(50, 45), P(20, 50), P(-10, 48), P(-10, 44)]] } },
{ type: 'Feature', properties: { highway: 'primary' }, geometry: { type: 'LineString', coordinates: ring } },
{ type: 'Feature', properties: { highway: 'trunk' }, geometry: { type: 'LineString', coordinates: [P(-10, 8), P(50, 10), P(110, 6)] } },
{ type: 'Feature', properties: { highway: 'residential' }, geometry: { type: 'MultiLineString', coordinates: streets } },
{ type: 'Feature', properties: { railway: 'rail' }, geometry: { type: 'LineString', coordinates: [P(-10, 95), P(40, 80), P(110, 92)] } },
{ type: 'Feature', properties: { name: 'Fenwick Market' }, geometry: { type: 'Point', coordinates: P(46, 56) } },
{ type: 'Feature', properties: { name: 'Riverside' }, geometry: { type: 'Point', coordinates: P(22, 38) } },
],
};
const names = [
['The Corner Bakery', 'Mill Lane'], ['Harbour Café', 'Quay Street'], ['Abbey Pharmacy', 'Abbey Road'], ['Green Leaf Grocers', 'Market Square'],
['St Anne\'s School', 'School Lane'], ['Fenwick Library', 'Church Street'], ['The Old Forge', 'Forge Row'], ['Riverside Care Home', 'River Walk'],
['Bridge Hardware', 'Bridge Street'], ['Kettle & Crumb', 'High Street'], ['Moor Farm Shop', 'Moor Road'], ['Northgate Surgery', 'Northgate'],
['Little Acorns Nursery', 'Oak Avenue'], ['The Fenwick Arms', 'Station Road'], ['Willow Florist', 'Willow Close'], ['Hill Top Garage', 'Hill Road'],
['Cobbler\'s Rest', 'Tanner\'s Yard'], ['Market Deli', 'Market Square'], ['Eastfield Leisure', 'Eastfield Way'], ['Rose Cottage B&B', 'Rose Lane'],
['Hollow Brewery', 'Brewery Lane'], ['Saxon Books', 'Saxon Street'], ['Southmead Hall', 'Southmead'], ['Pine Lodge', 'Pine Walk'],
['Westgate Dental', 'Westgate'], ['Fenwick Fire Station', 'Station Road'], ['Chapel Tearoom', 'Chapel Row'], ['Long Acre Stores', 'Long Acre'],
];
// Places spread over the town; some with time windows, two that need a fridge, one optional extra.
let seed = 11;
const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
const stops = names.map(([name, street], i) => {
const a = rand() * Math.PI * 2;
const r = 8 + rand() * 40;
const s = { id: `s${i + 1}`, name, address: `${3 + Math.floor(rand() * 90)} ${street}, Fenwick Market`, lon: lon(50 + r * Math.cos(a)), lat: lat(50 + r * Math.sin(a)), demand: [40 + Math.floor(rand() * 160), 2 + Math.floor(rand() * 10)], serviceMin: 5 + Math.floor(rand() * 4) * 5, contact: `01632 96${String(1000 + i * 37).slice(-4)}` };
if (i % 5 === 0) s.windows = [{ from: '08:00', to: '10:00' }];
if (i % 7 === 3) s.windows = [{ from: '13:00', to: '15:30' }];
if (i === 3 || i === 17) s.skills = ['fridge'];
if (i === 4) s.notes = 'Deliver to the kitchen door; sign in at reception first.';
if (i === 27) Object.assign(s, { required: false, value: 15, notes: 'Only if there is room: a small order.' });
return s;
});
planner.plan = {
name: 'Fenwick Market deliveries',
date: '2026-11-03',
measures: ['kg', 'crates'],
roadFactor: 1.35,
depots: [{ id: 'depot', name: 'Fenwick depot', lon: lon(12), lat: lat(12), opens: '06:30', closes: '19:00' }],
stops,
vehicles: [
{ id: 'van1', name: 'Van 1', capacity: [900, 60], shiftStart: '07:30', shiftEnd: '15:30', breakMin: 30, breakAfterMin: 240, speedKmh: 28, costPerKm: 0.45, costPerHour: 18, fixedCost: 40 },
{ id: 'van2', name: 'Van 2', capacity: [900, 60], shiftStart: '07:30', shiftEnd: '15:30', breakMin: 30, breakAfterMin: 240, speedKmh: 28, costPerKm: 0.45, costPerHour: 18, fixedCost: 40 },
{ id: 'van3', name: 'Van 3', capacity: [900, 60], shiftStart: '09:00', shiftEnd: '17:00', breakMin: 30, breakAfterMin: 240, speedKmh: 28, costPerKm: 0.45, costPerHour: 18, fixedCost: 40 },
{ id: 'chill', name: 'Chiller van', capacity: [600, 40], shiftStart: '07:00', shiftEnd: '15:00', breakMin: 30, breakAfterMin: 240, speedKmh: 26, costPerKm: 0.6, costPerHour: 20, fixedCost: 55, skills: ['fridge'] },
],
};
planner.addEventListener('bmxOptimise', async e => {
const r = await planner.getResult();
out.textContent = e.detail.improved
? `Optimised: ${r.vehiclesUsed} vehicles, ${r.km.toFixed(1)} km, running cost £${e.detail.cost.toFixed(2)}${e.detail.startCost ? ` (was £${e.detail.startCost.toFixed(2)})` : ''}. Now drag a stop to another route.`
: 'No better routes found.';
});
planner.addEventListener('bmxChange', async () => {
const r = await planner.getResult();
const issues = await planner.getIssues();
out.textContent = `${r.stopsServed} of ${planner.plan.stops.length} stops served, ${r.km.toFixed(1)} km, running cost £${r.runCost.toFixed(2)}. ${issues.length ? `${issues.length} issues to look at.` : 'No issues.'}`;
});
</script>
<bmx-route-planner src="/plans/tuesday.json" basemap-src="/maps/city.geojson"></bmx-route-planner>
Travel is the straight-line distance times a road factor at each
vehicle's speed, or the distance table your own routing service gives
(plan.matrix). No road network ships with it and nothing is fetched.
Everything runs in the page; nothing is uploaded.
Keyboard: in the routes list each stop takes focus with Tab; Alt with Up and Down moves it along its route, Alt with Left and Right to the previous or next vehicle, Delete takes it off its route, Enter shows its details. The map takes focus as one: the arrow keys move it, plus and minus zoom, 0 shows everything.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
basemap |
property only | unknown |
— | A map to draw under the routes: GeoJSON (roads, water, parks, the area served), as an object or text. |
basemapSrc |
basemap-src |
string |
— | An address to read the map's GeoJSON from. |
currency |
currency |
string |
'' |
Put before costs, such as £ or €. |
label |
label |
string |
— | The name shown in the toolbar and to assistive technology. Default: the plan's name. |
optimiseTime |
optimise-time |
number |
2000 |
How long the optimiser works, in milliseconds. |
panel |
panel |
boolean |
true |
Show the side panel. Two-way (the toolbar's panel button). |
plan |
plan |
BmxRoutePlan | string | null |
null |
The plan: depots, stops, vehicles and routes, as an object or JSON. Two-way: bmxChange reports every edit. |
readonly |
readonly |
boolean |
false |
The plan can be looked at but not changed (the optimiser is not offered). |
seed |
seed |
number |
1 |
Seed for the optimiser's random choices: the same seed and plan give the same routes. |
src |
src |
string |
— | An address to read the plan's JSON from. |
strings |
strings |
Partial<BmxRoutePlannerStrings> | string |
— | Words to show instead of the English ones, as an object or JSON. |
toolbar |
toolbar |
boolean |
true |
Show the toolbar. |
units |
units |
'metric' | 'imperial' |
'metric' |
Show distances in kilometres or miles. The plan's file is always in kilometres. |
Events
| Event | Detail | Description |
|---|---|---|
bmxChange |
{ readonly plan: BmxRoutePlan; } |
The plan changed (any edit, an optimisation, an undo or a redo). |
bmxError |
{ readonly message: string; } |
A plan or a map could not be read. |
bmxOptimise |
{ readonly startCost: number; readonly cost: number; readonly improved: boolean; } |
An optimisation finished: the running cost before and after, and whether the routes changed. |
bmxSelect |
{ readonly stop: string | null; readonly vehicle: string | null; readonly depot: string | null; } |
The selection changed. |
Methods
| Method | Signature | Description |
|---|---|---|
addStop |
addStop(stop: Partial<BmxRouteStop> & { lon: number; lat: number; }) => Promise<string> |
Adds a stop (not on a route). Returns its id. |
addVehicle |
addVehicle(vehicle?: Partial<BmxRouteVehicle>) => Promise<string> |
Adds a vehicle (by default like the last one). Returns its id. |
cancelOptimise |
cancelOptimise() => Promise<void> |
Stops an optimisation; the routes stay as they were. |
export |
export(kind: "json" | "stops" | "routes" | "sheets", options?: { vehicles?: string[]; paper?: "a4" | "letter"; }) => Promise<Blob> |
The plan or its parts as a file: the plan as JSON, the stops or routes as CSV, driver sheets as PDF (for some vehicles, or all used). |
fit |
fit() => Promise<void> |
Shows every depot and stop. |
getIssues |
getIssues() => Promise<BmxRouteIssue[]> |
Everything wrong or worth a look. |
getPlan |
getPlan() => Promise<BmxRoutePlan> |
The plan as it stands. |
getResult |
getResult() => Promise<BmxRoutePlanResult> |
What the plan comes to: each route's schedule, distance, time and cost, and the totals. |
importCsv |
importCsv(text: string) => Promise<{ added: number; skipped: number[]; }> |
Adds the stops in a CSV, or takes a distance table (a CSV whose first cell is empty). |
load |
load(data: string | BmxRoutePlan | Blob | Record<string, unknown>) => Promise<void> |
Opens a plan (JSON text, an object, or a file), or adds what a CSV holds: stops, or a distance table (its first cell empty). |
moveStop |
moveStop(stop: string, vehicle: string | null, index?: number) => Promise<void> |
Moves a stop to a vehicle's route at a position (from 0), or off every route (vehicle null). |
optimise |
optimise(options?: { fresh?: boolean; timeMs?: number; seed?: number; }) => Promise<BmxRouteSolveResult | null> |
Optimises the routes on a background thread. By default it improves the routes there are (building new ones when there are none); fresh builds them anew. Locked routes are left as they are. Resolves with the result, or null when cancelled. |
redo |
redo() => Promise<void> |
|
removeStop |
removeStop(id: string) => Promise<void> |
|
select |
select(kind: "stop" | "vehicle" | "depot" | null, id?: string) => Promise<void> |
Selects a stop, vehicle or depot (by id), or nothing. |
undo |
undo() => Promise<void> |
CSS shadow parts
| Part | Description |
|---|---|
map |
The map. |
panel |
The side panel (routes, schedule, details, issues). |
status |
The line under the map. |
toolbar |
The bar of commands. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-route-planner-built |
Built-up areas on the map. |
--bmx-route-planner-green |
Parks and woods on the map. |
--bmx-route-planner-height |
The height of the map. |
--bmx-route-planner-land |
The map's background. |
--bmx-route-planner-panel-width |
The side panel's width. |
--bmx-route-planner-radius |
Corner radius of the frame. |
--bmx-route-planner-road |
Roads on the map. |
--bmx-route-planner-selected |
The selected stop's ring. |
--bmx-route-planner-water |
Water on the map. |