v3.0.0

<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

PropertyAttributeTypeDefaultDescription
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

EventDetailDescription
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

MethodSignatureDescription
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

PartDescription
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

PropertyDescription
--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.