<!-- Demo layout: info bar + canvas for route visualization + control buttons -->
<div class="info-bar">
<span id="total-dist">Total distance: —</span>
<span id="step-info"></span>
</div>
<canvas id="canvas" width="360" height="270"></canvas>
<p class="hint-txt" id="hint-txt">Each customer has its own out-and-back trip. Press Step to apply the best savings merge.</p>
<div class="btns">
<button id="btn-step" type="button">Step</button>
<button id="btn-run" type="button">Run all</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
/* Styles for the Clarke-Wright savings demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.info-bar { display: flex; gap: 1rem; font-size: .85rem; font-weight: 600;
margin-bottom: .4rem; flex-wrap: wrap; }
#total-dist { color: #1d3557; }
#step-info { color: #6c757d; }
canvas { display: block; border: 1px solid #dde3e8; border-radius: 8px;
background: #f8fafc; width: 100%; max-width: 360px; height: auto; }
.hint-txt { font-size: .82rem; color: #555; margin: .4rem 0; min-height: 2em; line-height: 1.4; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .85rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Clarke-Wright Savings Algorithm
// 1. Start with one route per customer (depot -> customer -> depot).
// 2. Compute savings s(i,j) = d(0,i) + d(0,j) - d(i,j) for all pairs.
// 3. Greedily merge the highest-saving pair that respects capacity.
// Depot node (center of canvas)
const DEPOT = { x: 180, y: 135, label: 'D' };
// Customer nodes with positions and unit demand
const CUSTOMERS = [
{ x: 60, y: 60, label: 'A', demand: 1 },
{ x: 290, y: 55, label: 'B', demand: 1 },
{ x: 310, y: 180, label: 'C', demand: 1 },
{ x: 240, y: 240, label: 'D', demand: 1 },
{ x: 80, y: 230, label: 'E', demand: 1 },
{ x: 150, y: 170, label: 'F', demand: 1 }
];
// Truck capacity: max customers per route
const CAPACITY = 3;
function dist(a, b) {
// Euclidean distance between two points
const dx = a.x - b.x, dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
// Compute and sort all pairwise savings (descending)
function computeSavings() {
const n = CUSTOMERS.length;
const sav = [];
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const s = dist(DEPOT, CUSTOMERS[i]) + dist(DEPOT, CUSTOMERS[j]) - dist(CUSTOMERS[i], CUSTOMERS[j]);
sav.push({ i, j, s });
}
}
sav.sort((a, b) => b.s - a.s);
return sav;
}
// Mutable state: list of routes, sorted savings, step counter
let routes = [];
let savings = [];
let stepsDone = 0;
let mergeCount = 0;
// Initialize: one singleton route per customer
function initRoutes() {
routes = CUSTOMERS.map((c, idx) => ({ nodes: [idx], load: c.demand }));
savings = computeSavings();
stepsDone = 0;
mergeCount = 0;
}
// Sum of all route distances (depot out + edges + depot back)
function totalDistance() {
let d = 0;
for (const r of routes) {
// Leg from depot to first customer
d += dist(DEPOT, CUSTOMERS[r.nodes[0]]);
for (let k = 0; k < r.nodes.length - 1; k++) {
d += dist(CUSTOMERS[r.nodes[k]], CUSTOMERS[r.nodes[k + 1]]);
}
// Leg from last customer back to depot
d += dist(CUSTOMERS[r.nodes[r.nodes.length - 1]], DEPOT);
}
return d;
}
// Check if merging routes rA and rB at endpoints si->sj is feasible
function canMerge(rA, rB, si, sj) {
// Merge is valid only if si is the tail of rA and sj is the head of rB
// or the reverse — and the combined load must not exceed CAPACITY
const endA = rA.nodes[rA.nodes.length - 1] === si;
const startB = rB.nodes[0] === sj;
const startA = rA.nodes[0] === si;
const endB = rB.nodes[rB.nodes.length - 1] === sj;
const ok1 = endA && startB;
const ok2 = startA && endB;
if (!ok1 && !ok2) return null;
if (rA.load + rB.load > CAPACITY) return null;
return ok1 ? 'forward' : 'reverse';
}
// Concatenate routes in the correct direction
function doMerge(rA, rB, dir) {
if (dir === 'forward') {
return { nodes: [...rA.nodes, ...rB.nodes], load: rA.load + rB.load };
}
// Reverse merge: B’s tail meets A’s head
return { nodes: [...rB.nodes, ...rA.nodes], load: rA.load + rB.load };
}
// Apply the next feasible merge from the sorted savings list
function step() {
while (stepsDone < savings.length) {
const sv = savings[stepsDone];
stepsDone++;
const { i, j } = sv;
// Find which routes currently contain customers i and j
let rIdxA = -1, rIdxB = -1;
for (let k = 0; k < routes.length; k++) {
if (routes[k].nodes.includes(i)) rIdxA = k;
if (routes[k].nodes.includes(j)) rIdxB = k;
}
if (rIdxA === -1 || rIdxB === -1 || rIdxA === rIdxB) continue;
const dir = canMerge(routes[rIdxA], routes[rIdxB], i, j);
if (!dir) {
// Try the other direction (j at tail of rB, i at head of rA)
const dir2 = canMerge(routes[rIdxB], routes[rIdxA], j, i);
if (!dir2) continue;
const merged = doMerge(routes[rIdxB], routes[rIdxA], dir2);
const keep = routes.filter((_, k) => k !== rIdxA && k !== rIdxB);
routes = [...keep, merged];
} else {
const merged = doMerge(routes[rIdxA], routes[rIdxB], dir);
const keep = routes.filter((_, k) => k !== rIdxA && k !== rIdxB);
routes = [...keep, merged];
}
mergeCount++;
return true;
}
return false;
}
// Draw all routes, depot, and customers on the canvas
const ROUTE_COLORS = ['#e63946', '#457b9d', '#2a9d8f', '#e9c46a', '#f4a261', '#8338ec'];
function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw each route as a colored polyline from depot to customers and back
routes.forEach((r, ri) => {
ctx.strokeStyle = ROUTE_COLORS[ri % ROUTE_COLORS.length];
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(DEPOT.x, DEPOT.y);
r.nodes.forEach(ni => ctx.lineTo(CUSTOMERS[ni].x, CUSTOMERS[ni].y));
ctx.lineTo(DEPOT.x, DEPOT.y);
ctx.stroke();
});
// Draw the depot as a filled circle with ’D’ label
ctx.fillStyle = '#1d3557';
ctx.beginPath();
ctx.arc(DEPOT.x, DEPOT.y, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 9px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('D', DEPOT.x, DEPOT.y);
// Draw each customer as a white circle with a letter label
CUSTOMERS.forEach((c, i) => {
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(c.x, c.y, 10, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#1d3557';
ctx.font = 'bold 10px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(c.label, c.x, c.y);
});
}
function updateUI() {
const d = totalDistance();
document.getElementById('total-dist').textContent =
i18nFmt('Total distance: %d% px', { d: Math.round(d) });
const done = stepsDone >= savings.length;
document.getElementById('btn-step').disabled = done;
document.getElementById('btn-run').disabled = done;
if (done) {
document.getElementById('hint-txt').textContent =
i18nFmt('All %m% merge(s) applied — %r% route(s) remain. That’s the Clarke-Wright solution!', { m: mergeCount, r: routes.length });
document.getElementById('step-info').textContent = 'Done';
}
}
document.getElementById('btn-step').addEventListener('click', () => {
const merged = step();
draw();
updateUI();
if (merged) {
document.getElementById('step-info').textContent =
i18nFmt('Step %s% / %t%', { s: stepsDone, t: savings.length });
document.getElementById('hint-txt').textContent =
i18nFmt('Merged %m% time(s) — now %r% route(s).', { m: mergeCount, r: routes.length });
}
});
document.getElementById('btn-run').addEventListener('click', () => {
while (step()) {}
draw();
updateUI();
});
document.getElementById('btn-reset').addEventListener('click', () => {
initRoutes();
draw();
document.getElementById('total-dist').textContent =
i18nFmt('Total distance: %d% px', { d: Math.round(totalDistance()) });
document.getElementById('step-info').textContent = '';
document.getElementById('hint-txt').textContent = 'Each customer has its own out-and-back trip. Press Step to apply the best savings merge.';
document.getElementById('btn-step').disabled = false;
document.getElementById('btn-run').disabled = false;
});
initRoutes();
draw();
document.getElementById('total-dist').textContent =
i18nFmt('Total distance: %d% px', { d: Math.round(totalDistance()) });
<!-- Demo layout: info bar + canvas for route visualization + control buttons -->
<div class="info-bar">
<span id="total-dist">Total distance: —</span>
<span id="step-info"></span>
</div>
<canvas id="canvas" width="360" height="270"></canvas>
<p class="hint-txt" id="hint-txt">Each customer has its own out-and-back trip. Press Step to apply the best savings merge.</p>
<div class="btns">
<button id="btn-step" type="button">Step</button>
<button id="btn-run" type="button">Run all</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
/* Styles for the Clarke-Wright savings demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.info-bar { display: flex; gap: 1rem; font-size: .85rem; font-weight: 600;
margin-bottom: .4rem; flex-wrap: wrap; }
#total-dist { color: #1d3557; }
#step-info { color: #6c757d; }
canvas { display: block; border: 1px solid #dde3e8; border-radius: 8px;
background: #f8fafc; width: 100%; max-width: 360px; height: auto; }
.hint-txt { font-size: .82rem; color: #555; margin: .4rem 0; min-height: 2em; line-height: 1.4; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .85rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Clarke-Wright Savings Algorithm
// 1. Start with one route per customer (depot -> customer -> depot).
// 2. Compute savings s(i,j) = d(0,i) + d(0,j) - d(i,j) for all pairs.
// 3. Greedily merge the highest-saving pair that respects capacity.
// Depot node (center of canvas)
const DEPOT = { x: 180, y: 135, label: 'D' };
// Customer nodes with positions and unit demand
const CUSTOMERS = [
{ x: 60, y: 60, label: 'A', demand: 1 },
{ x: 290, y: 55, label: 'B', demand: 1 },
{ x: 310, y: 180, label: 'C', demand: 1 },
{ x: 240, y: 240, label: 'D', demand: 1 },
{ x: 80, y: 230, label: 'E', demand: 1 },
{ x: 150, y: 170, label: 'F', demand: 1 }
];
// Truck capacity: max customers per route
const CAPACITY = 3;
function dist(a, b) {
// Euclidean distance between two points
const dx = a.x - b.x, dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
// Compute and sort all pairwise savings (descending)
function computeSavings() {
const n = CUSTOMERS.length;
const sav = [];
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const s = dist(DEPOT, CUSTOMERS[i]) + dist(DEPOT, CUSTOMERS[j]) - dist(CUSTOMERS[i], CUSTOMERS[j]);
sav.push({ i, j, s });
}
}
sav.sort((a, b) => b.s - a.s);
return sav;
}
// Mutable state: list of routes, sorted savings, step counter
let routes = [];
let savings = [];
let stepsDone = 0;
let mergeCount = 0;
// Initialize: one singleton route per customer
function initRoutes() {
routes = CUSTOMERS.map((c, idx) => ({ nodes: [idx], load: c.demand }));
savings = computeSavings();
stepsDone = 0;
mergeCount = 0;
}
// Sum of all route distances (depot out + edges + depot back)
function totalDistance() {
let d = 0;
for (const r of routes) {
// Leg from depot to first customer
d += dist(DEPOT, CUSTOMERS[r.nodes[0]]);
for (let k = 0; k < r.nodes.length - 1; k++) {
d += dist(CUSTOMERS[r.nodes[k]], CUSTOMERS[r.nodes[k + 1]]);
}
// Leg from last customer back to depot
d += dist(CUSTOMERS[r.nodes[r.nodes.length - 1]], DEPOT);
}
return d;
}
// Check if merging routes rA and rB at endpoints si->sj is feasible
function canMerge(rA, rB, si, sj) {
// Merge is valid only if si is the tail of rA and sj is the head of rB
// or the reverse — and the combined load must not exceed CAPACITY
const endA = rA.nodes[rA.nodes.length - 1] === si;
const startB = rB.nodes[0] === sj;
const startA = rA.nodes[0] === si;
const endB = rB.nodes[rB.nodes.length - 1] === sj;
const ok1 = endA && startB;
const ok2 = startA && endB;
if (!ok1 && !ok2) return null;
if (rA.load + rB.load > CAPACITY) return null;
return ok1 ? 'forward' : 'reverse';
}
// Concatenate routes in the correct direction
function doMerge(rA, rB, dir) {
if (dir === 'forward') {
return { nodes: [...rA.nodes, ...rB.nodes], load: rA.load + rB.load };
}
// Reverse merge: B’s tail meets A’s head
return { nodes: [...rB.nodes, ...rA.nodes], load: rA.load + rB.load };
}
// Apply the next feasible merge from the sorted savings list
function step() {
while (stepsDone < savings.length) {
const sv = savings[stepsDone];
stepsDone++;
const { i, j } = sv;
// Find which routes currently contain customers i and j
let rIdxA = -1, rIdxB = -1;
for (let k = 0; k < routes.length; k++) {
if (routes[k].nodes.includes(i)) rIdxA = k;
if (routes[k].nodes.includes(j)) rIdxB = k;
}
if (rIdxA === -1 || rIdxB === -1 || rIdxA === rIdxB) continue;
const dir = canMerge(routes[rIdxA], routes[rIdxB], i, j);
if (!dir) {
// Try the other direction (j at tail of rB, i at head of rA)
const dir2 = canMerge(routes[rIdxB], routes[rIdxA], j, i);
if (!dir2) continue;
const merged = doMerge(routes[rIdxB], routes[rIdxA], dir2);
const keep = routes.filter((_, k) => k !== rIdxA && k !== rIdxB);
routes = [...keep, merged];
} else {
const merged = doMerge(routes[rIdxA], routes[rIdxB], dir);
const keep = routes.filter((_, k) => k !== rIdxA && k !== rIdxB);
routes = [...keep, merged];
}
mergeCount++;
return true;
}
return false;
}
// Draw all routes, depot, and customers on the canvas
const ROUTE_COLORS = ['#e63946', '#457b9d', '#2a9d8f', '#e9c46a', '#f4a261', '#8338ec'];
function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw each route as a colored polyline from depot to customers and back
routes.forEach((r, ri) => {
ctx.strokeStyle = ROUTE_COLORS[ri % ROUTE_COLORS.length];
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(DEPOT.x, DEPOT.y);
r.nodes.forEach(ni => ctx.lineTo(CUSTOMERS[ni].x, CUSTOMERS[ni].y));
ctx.lineTo(DEPOT.x, DEPOT.y);
ctx.stroke();
});
// Draw the depot as a filled circle with ’D’ label
ctx.fillStyle = '#1d3557';
ctx.beginPath();
ctx.arc(DEPOT.x, DEPOT.y, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 9px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('D', DEPOT.x, DEPOT.y);
// Draw each customer as a white circle with a letter label
CUSTOMERS.forEach((c, i) => {
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(c.x, c.y, 10, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#1d3557';
ctx.font = 'bold 10px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(c.label, c.x, c.y);
});
}
function updateUI() {
const d = totalDistance();
document.getElementById('total-dist').textContent =
i18nFmt('Total distance: %d% px', { d: Math.round(d) });
const done = stepsDone >= savings.length;
document.getElementById('btn-step').disabled = done;
document.getElementById('btn-run').disabled = done;
if (done) {
document.getElementById('hint-txt').textContent =
i18nFmt('All %m% merge(s) applied — %r% route(s) remain. That’s the Clarke-Wright solution!', { m: mergeCount, r: routes.length });
document.getElementById('step-info').textContent = 'Done';
}
}
document.getElementById('btn-step').addEventListener('click', () => {
const merged = step();
draw();
updateUI();
if (merged) {
document.getElementById('step-info').textContent =
i18nFmt('Step %s% / %t%', { s: stepsDone, t: savings.length });
document.getElementById('hint-txt').textContent =
i18nFmt('Merged %m% time(s) — now %r% route(s).', { m: mergeCount, r: routes.length });
}
});
document.getElementById('btn-run').addEventListener('click', () => {
while (step()) {}
draw();
updateUI();
});
document.getElementById('btn-reset').addEventListener('click', () => {
initRoutes();
draw();
document.getElementById('total-dist').textContent =
i18nFmt('Total distance: %d% px', { d: Math.round(totalDistance()) });
document.getElementById('step-info').textContent = '';
document.getElementById('hint-txt').textContent = 'Each customer has its own out-and-back trip. Press Step to apply the best savings merge.';
document.getElementById('btn-step').disabled = false;
document.getElementById('btn-run').disabled = false;
});
initRoutes();
draw();
document.getElementById('total-dist').textContent =
i18nFmt('Total distance: %d% px', { d: Math.round(totalDistance()) });
Comments
Loading comments...