Below is a depot (the square) and eight delivery stops. Two trucks must cover them all. Click a stop to send it to the other truck, and each truck instantly re-orders its own stops with a quick nearest-neighbor tour. Watch the total length at the bottom.
<p class="hint">The square is the <b>depot</b>; the circles are delivery stops. Two trucks must cover them all. Click a stop to <b>move it to the other truck</b> — each truck instantly re-routes itself with a nearest-neighbor tour. Lower total = better.</p>
<svg id="map" viewBox="0 0 320 240" role="img" aria-label="Routing map"></svg>
<div class="legend">
<span><i class="sw a"></i>Truck A <b id="lenA">0</b></span>
<span><i class="sw b"></i>Truck B <b id="lenB">0</b></span>
<span class="tot">Total <b id="total">0</b></span>
</div>
<div class="btns">
<button id="balance" type="button">Auto-balance</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<div class="status" id="status">Click any stop to reassign it.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
#map { width: 100%; max-width: 460px; height: auto; background: #f5f7fa;
border: 1px solid #d7dee6; border-radius: 10px; display: block; }
.stop { cursor: pointer; }
.stop circle { transition: r .1s; }
.stop:hover circle { r: 9; }
.legend { display: flex; gap: 1.1rem; flex-wrap: wrap; align-items: center;
font-size: .92rem; margin: .6rem 0; }
.legend b { font-variant-numeric: tabular-nums; }
.legend .tot { font-weight: 600; }
.sw { display: inline-block; width: 14px; height: 4px; border-radius: 2px; margin-right: .4rem; vertical-align: middle; }
.sw.a { background: #2563eb; } .sw.b { background: #e8590c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.3em; color: #0a7d33; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Vehicle Routing (2 trucks, 1 depot). Each truck visits its assigned stops with a nearest-neighbor tour starting and ending at the depot; the route length is the sum of leg distances. The total cost is lenA + lenB. Clicking a stop flips which truck owns it. "Auto-balance" brute-forces all 2^8 assignments and keeps the best — exactly the kind of exponential search that makes VRP NP-hard at real scale.
const DEPOT = { x: 160, y: 120 };
const STOPS = [
{ x: 60, y: 50 }, { x: 110, y: 40 }, { x: 250, y: 55 }, { x: 285, y: 110 },
{ x: 250, y: 190 }, { x: 150, y: 205 }, { x: 55, y: 180 }, { x: 40, y: 110 }
];
// assign[i] = 0 (Truck A) or 1 (Truck B)
let assign = STOPS.map((_, i) => (i % 2));
const dist = (p, q) => Math.hypot(p.x - q.x, p.y - q.y);
// Nearest-neighbor tour from the depot through `idxs`, back to depot. Returns {len, order} where order is the visiting sequence of stop indices.
function tour(idxs) {
if (idxs.length === 0) return { len: 0, order: [] };
const remaining = idxs.slice();
const order = [];
let cur = DEPOT, len = 0;
while (remaining.length) {
let best = 0;
for (let k = 1; k < remaining.length; k++)
if (dist(cur, STOPS[remaining[k]]) < dist(cur, STOPS[remaining[best]])) best = k;
const next = remaining.splice(best, 1)[0];
len += dist(cur, STOPS[next]); order.push(next); cur = STOPS[next];
}
len += dist(cur, DEPOT); // return to depot
return { len, order };
}
function costOf(a) {
const A = [], B = [];
a.forEach((t, i) => (t === 0 ? A : B).push(i));
const ta = tour(A), tb = tour(B);
return { lenA: ta.len, lenB: tb.len, total: ta.len + tb.len, ta, tb, A, B };
}
function path(d, color) {
if (!d.order.length) return '';
const pts = [DEPOT, ...d.order.map(i => STOPS[i]), DEPOT];
const dd = pts.map((p, i) => (i ? 'L' : 'M') + p.x + ' ' + p.y).join(' ');
return `<path d="${dd}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linejoin="round" opacity="0.85"/>`;
}
function render() {
const c = costOf(assign);
const svg = document.getElementById('map');
let s = path(c.ta, '#2563eb') + path(c.tb, '#e8590c');
STOPS.forEach((p, i) => {
const col = assign[i] === 0 ? '#2563eb' : '#e8590c';
s += `<g class="stop" data-i="${i}"><circle cx="${p.x}" cy="${p.y}" r="7" fill="${col}" stroke="#fff" stroke-width="1.5"/></g>`;
});
s += `<rect x="${DEPOT.x - 9}" y="${DEPOT.y - 9}" width="18" height="18" rx="3" fill="#1d3557" stroke="#fff" stroke-width="1.5"/>`;
svg.innerHTML = s;
svg.querySelectorAll('.stop').forEach(g => g.addEventListener('click', () => {
const i = +g.dataset.i; assign[i] = assign[i] === 0 ? 1 : 0;
setStatus('Stop reassigned — routes recomputed.'); render();
}));
document.getElementById('lenA').textContent = Math.round(c.lenA);
document.getElementById('lenB').textContent = Math.round(c.lenB);
document.getElementById('total').textContent = Math.round(c.total);
}
function setStatus(t) { document.getElementById('status').textContent = t; }
// Brute force: try every 0/1 assignment of the 8 stops, keep the cheapest total.
function balance() {
const n = STOPS.length; let best = null, bestCost = Infinity, checked = 0;
for (let mask = 0; mask < (1 << n); mask++) {
checked++;
const a = []; for (let i = 0; i < n; i++) a.push((mask >> i) & 1);
const t = costOf(a).total;
if (t < bestCost) { bestCost = t; best = a; }
}
assign = best; render();
setStatus(fmt('Best split found after checking %checked% of %v% assignments.', { checked: checked, v: 1 << n }));
console.log('Optimal (under NN routing) total =', Math.round(bestCost), '| checked', checked, 'splits');
}
document.getElementById('balance').addEventListener('click', balance);
document.getElementById('reset').addEventListener('click', () => {
assign = STOPS.map((_, i) => (i % 2)); setStatus('Click any stop to reassign it.'); render();
});
render();
<p class="hint">The square is the <b>depot</b>; the circles are delivery stops. Two trucks must cover them all. Click a stop to <b>move it to the other truck</b> — each truck instantly re-routes itself with a nearest-neighbor tour. Lower total = better.</p>
<svg id="map" viewBox="0 0 320 240" role="img" aria-label="Routing map"></svg>
<div class="legend">
<span><i class="sw a"></i>Truck A <b id="lenA">0</b></span>
<span><i class="sw b"></i>Truck B <b id="lenB">0</b></span>
<span class="tot">Total <b id="total">0</b></span>
</div>
<div class="btns">
<button id="balance" type="button">Auto-balance</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<div class="status" id="status">Click any stop to reassign it.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
#map { width: 100%; max-width: 460px; height: auto; background: #f5f7fa;
border: 1px solid #d7dee6; border-radius: 10px; display: block; }
.stop { cursor: pointer; }
.stop circle { transition: r .1s; }
.stop:hover circle { r: 9; }
.legend { display: flex; gap: 1.1rem; flex-wrap: wrap; align-items: center;
font-size: .92rem; margin: .6rem 0; }
.legend b { font-variant-numeric: tabular-nums; }
.legend .tot { font-weight: 600; }
.sw { display: inline-block; width: 14px; height: 4px; border-radius: 2px; margin-right: .4rem; vertical-align: middle; }
.sw.a { background: #2563eb; } .sw.b { background: #e8590c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.3em; color: #0a7d33; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Vehicle Routing (2 trucks, 1 depot). Each truck visits its assigned stops with a nearest-neighbor tour starting and ending at the depot; the route length is the sum of leg distances. The total cost is lenA + lenB. Clicking a stop flips which truck owns it. "Auto-balance" brute-forces all 2^8 assignments and keeps the best — exactly the kind of exponential search that makes VRP NP-hard at real scale.
const DEPOT = { x: 160, y: 120 };
const STOPS = [
{ x: 60, y: 50 }, { x: 110, y: 40 }, { x: 250, y: 55 }, { x: 285, y: 110 },
{ x: 250, y: 190 }, { x: 150, y: 205 }, { x: 55, y: 180 }, { x: 40, y: 110 }
];
// assign[i] = 0 (Truck A) or 1 (Truck B)
let assign = STOPS.map((_, i) => (i % 2));
const dist = (p, q) => Math.hypot(p.x - q.x, p.y - q.y);
// Nearest-neighbor tour from the depot through `idxs`, back to depot. Returns {len, order} where order is the visiting sequence of stop indices.
function tour(idxs) {
if (idxs.length === 0) return { len: 0, order: [] };
const remaining = idxs.slice();
const order = [];
let cur = DEPOT, len = 0;
while (remaining.length) {
let best = 0;
for (let k = 1; k < remaining.length; k++)
if (dist(cur, STOPS[remaining[k]]) < dist(cur, STOPS[remaining[best]])) best = k;
const next = remaining.splice(best, 1)[0];
len += dist(cur, STOPS[next]); order.push(next); cur = STOPS[next];
}
len += dist(cur, DEPOT); // return to depot
return { len, order };
}
function costOf(a) {
const A = [], B = [];
a.forEach((t, i) => (t === 0 ? A : B).push(i));
const ta = tour(A), tb = tour(B);
return { lenA: ta.len, lenB: tb.len, total: ta.len + tb.len, ta, tb, A, B };
}
function path(d, color) {
if (!d.order.length) return '';
const pts = [DEPOT, ...d.order.map(i => STOPS[i]), DEPOT];
const dd = pts.map((p, i) => (i ? 'L' : 'M') + p.x + ' ' + p.y).join(' ');
return `<path d="${dd}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linejoin="round" opacity="0.85"/>`;
}
function render() {
const c = costOf(assign);
const svg = document.getElementById('map');
let s = path(c.ta, '#2563eb') + path(c.tb, '#e8590c');
STOPS.forEach((p, i) => {
const col = assign[i] === 0 ? '#2563eb' : '#e8590c';
s += `<g class="stop" data-i="${i}"><circle cx="${p.x}" cy="${p.y}" r="7" fill="${col}" stroke="#fff" stroke-width="1.5"/></g>`;
});
s += `<rect x="${DEPOT.x - 9}" y="${DEPOT.y - 9}" width="18" height="18" rx="3" fill="#1d3557" stroke="#fff" stroke-width="1.5"/>`;
svg.innerHTML = s;
svg.querySelectorAll('.stop').forEach(g => g.addEventListener('click', () => {
const i = +g.dataset.i; assign[i] = assign[i] === 0 ? 1 : 0;
setStatus('Stop reassigned — routes recomputed.'); render();
}));
document.getElementById('lenA').textContent = Math.round(c.lenA);
document.getElementById('lenB').textContent = Math.round(c.lenB);
document.getElementById('total').textContent = Math.round(c.total);
}
function setStatus(t) { document.getElementById('status').textContent = t; }
// Brute force: try every 0/1 assignment of the 8 stops, keep the cheapest total.
function balance() {
const n = STOPS.length; let best = null, bestCost = Infinity, checked = 0;
for (let mask = 0; mask < (1 << n); mask++) {
checked++;
const a = []; for (let i = 0; i < n; i++) a.push((mask >> i) & 1);
const t = costOf(a).total;
if (t < bestCost) { bestCost = t; best = a; }
}
assign = best; render();
setStatus(fmt('Best split found after checking %checked% of %v% assignments.', { checked: checked, v: 1 << n }));
console.log('Optimal (under NN routing) total =', Math.round(bestCost), '| checked', checked, 'splits');
}
document.getElementById('balance').addEventListener('click', balance);
document.getElementById('reset').addEventListener('click', () => {
assign = STOPS.map((_, i) => (i % 2)); setStatus('Click any stop to reassign it.'); render();
});
render();
Try to make the total as small as you can, then press Auto-balance to let the computer search. Notice the tension: each truck wants a short, tight loop, but the two loops must together cover every stop. With just eight stops there are already 28 = 256 ways to split them between two trucks — and real fleets have hundreds of stops and many trucks, where the number of splits is astronomically larger.
Comments
Loading comments...