Below are 20 cities scattered at random. The algorithm starts with a random tour (the red path connecting all cities) and tries to shorten it by simulated annealing. At each step it picks two random edges, considers swapping them (2-opt move), and accepts the swap immediately if it shortens the tour โ or with a shrinking probability if it makes the tour longer.
<div class="controls">
<button id="annealBtn" type="button">Anneal</button>
<button id="resetBtn" type="button" class="ghost">New cities</button>
<span class="temp-label">T = <span id="tempVal">โ</span></span>
</div>
<canvas id="canvas" width="460" height="300"></canvas>
<div class="stats">
<span>Tour length: <b id="tourLen">โ</b></span>
<span>Best: <b id="bestLen">โ</b></span>
<span id="statusMsg"></span>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; background: #fff; }
.controls { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.temp-label { font: 600 13px ui-monospace, monospace; color: #555; margin-left: .3rem; }
canvas { display: block; border: 1px solid #d0d7de; border-radius: 8px; background: #f8fafc;
max-width: 100%; }
.stats { display: flex; gap: 1.2rem; flex-wrap: wrap; margin-top: .45rem;
font-size: .88rem; color: #444; }
.stats b { color: #1d3557; }
#statusMsg { font-weight: 600; color: #0a7d33; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simulated Annealing for TSP (2-opt moves).
// Acceptance: exp(-delta / T). Temperature schedule: geometric T *= alpha each step.
const N_CITIES = 20;
const W = 460, H = 300, PAD = 22;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let cities = [], tour = [], bestTour = [], bestLen = Infinity;
let animId = null, running = false;
// โโ Utilities โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function dist(a, b) {
const dx = a[0]-b[0], dy = a[1]-b[1];
return Math.sqrt(dx*dx + dy*dy);
}
function tourLength(t) {
let s = 0;
for (let i = 0; i < t.length; i++) s += dist(cities[t[i]], cities[t[(i+1)%t.length]]);
return s;
}
function randInt(lo, hi) { return lo + Math.floor(Math.random() * (hi - lo)); }
// โโ Initialization โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function initCities() {
cities = [];
for (let i = 0; i < N_CITIES; i++)
cities.push([PAD + Math.random()*(W-2*PAD), PAD + Math.random()*(H-2*PAD)]);
tour = Array.from({length: N_CITIES}, (_, i) => i);
// shuffle
for (let i = tour.length-1; i > 0; i--) {
const j = randInt(0, i+1);
[tour[i], tour[j]] = [tour[j], tour[i]];
}
bestTour = tour.slice();
bestLen = tourLength(tour);
}
// โโ Drawing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function draw(t, temperature) {
ctx.clearRect(0, 0, W, H);
// Tour path
const hot = Math.max(0, Math.min(1, temperature / 500));
// Gradient red(hot) -> blue(cold)
const r = Math.round(hot * 200 + (1-hot) * 29);
const g = Math.round(hot * 40 + (1-hot) * 100);
const b = Math.round(hot * 40 + (1-hot) * 200);
ctx.strokeStyle = `rgb(${r},${g},${b})`;
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(cities[t[0]][0], cities[t[0]][1]);
for (let i = 1; i < t.length; i++) ctx.lineTo(cities[t[i]][0], cities[t[i]][1]);
ctx.closePath();
ctx.stroke();
// City dots
for (let i = 0; i < cities.length; i++) {
ctx.beginPath();
ctx.arc(cities[i][0], cities[i][1], 4, 0, Math.PI*2);
ctx.fillStyle = '#1d3557';
ctx.fill();
}
}
function updateUI(len, T) {
document.getElementById('tourLen').textContent = len.toFixed(1);
document.getElementById('bestLen').textContent = bestLen.toFixed(1);
document.getElementById('tempVal').textContent = T < 0.01 ? T.toExponential(1) : T.toFixed(1);
}
// โโ Annealing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function anneal() {
if (running) return;
running = true;
document.getElementById('annealBtn').disabled = true;
document.getElementById('statusMsg').textContent = '';
let T = 800; // initial temperature
const alpha = 0.992; // cooling rate
const stepsPerFrame = 120;
let currentLen = tourLength(tour);
function step() {
for (let s = 0; s < stepsPerFrame; s++) {
// 2-opt: pick two distinct indices i < j, reverse segment [i+1..j]
let i = randInt(0, N_CITIES - 1);
let j = randInt(i + 1, N_CITIES);
if (j - i < 2) { T *= alpha; continue; }
// Delta for reversing tour[i+1..j]
const a = tour[i], b = tour[(i+1) % N_CITIES];
const c = tour[j], d = tour[(j+1) % N_CITIES];
const before = dist(cities[a], cities[b]) + dist(cities[c], cities[d]);
const after = dist(cities[a], cities[c]) + dist(cities[b], cities[d]);
const delta = after - before;
if (delta < 0 || Math.random() < Math.exp(-delta / T)) {
// Accept: reverse the segment
let lo = i + 1, hi = j;
while (lo < hi) { [tour[lo], tour[hi]] = [tour[hi], tour[lo]]; lo++; hi--; }
currentLen += delta;
}
T *= alpha;
}
if (currentLen < bestLen) {
bestLen = currentLen;
bestTour = tour.slice();
}
draw(tour, T);
updateUI(currentLen, T);
if (T > 0.1) {
animId = requestAnimationFrame(step);
} else {
// Show best
tour = bestTour.slice();
currentLen = bestLen;
draw(tour, 0);
updateUI(currentLen, 0);
document.getElementById('statusMsg').textContent = 'โ Done โ best tour shown';
document.getElementById('annealBtn').disabled = false;
running = false;
}
}
animId = requestAnimationFrame(step);
}
function reset() {
if (animId) { cancelAnimationFrame(animId); animId = null; }
running = false;
document.getElementById('annealBtn').disabled = false;
document.getElementById('statusMsg').textContent = '';
initCities();
draw(tour, 800);
updateUI(tourLength(tour), 800);
}
document.getElementById('annealBtn').addEventListener('click', anneal);
document.getElementById('resetBtn').addEventListener('click', reset);
// Boot
initCities();
draw(tour, 800);
updateUI(tourLength(tour), 800);
// Verify core SA logic (runs in node too)
// assert: accepting a worse move when T is high has probability close to 1
const p_high = Math.exp(-1 / 800); // delta=1, T=800 โ p โ 0.9987
const p_low = Math.exp(-1 / 0.1); // delta=1, T=0.1 โ p โ 5e-5
console.assert(p_high > 0.99, fmt('High-T acceptance should be > 0.99, got %p_high%', { p_high: p_high }));
console.assert(p_low < 0.01, fmt('Low-T acceptance should be < 0.01, got %p_low%', { p_low: p_low }));
console.log(fmt('SA acceptance probabilities: T=800 p=%p_high%, T=0.1 p=%toExponential%', { p_high: p_high.toFixed(4), toExponential: p_low.toExponential(2) }));
<div class="controls">
<button id="annealBtn" type="button">Anneal</button>
<button id="resetBtn" type="button" class="ghost">New cities</button>
<span class="temp-label">T = <span id="tempVal">โ</span></span>
</div>
<canvas id="canvas" width="460" height="300"></canvas>
<div class="stats">
<span>Tour length: <b id="tourLen">โ</b></span>
<span>Best: <b id="bestLen">โ</b></span>
<span id="statusMsg"></span>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; background: #fff; }
.controls { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.temp-label { font: 600 13px ui-monospace, monospace; color: #555; margin-left: .3rem; }
canvas { display: block; border: 1px solid #d0d7de; border-radius: 8px; background: #f8fafc;
max-width: 100%; }
.stats { display: flex; gap: 1.2rem; flex-wrap: wrap; margin-top: .45rem;
font-size: .88rem; color: #444; }
.stats b { color: #1d3557; }
#statusMsg { font-weight: 600; color: #0a7d33; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simulated Annealing for TSP (2-opt moves).
// Acceptance: exp(-delta / T). Temperature schedule: geometric T *= alpha each step.
const N_CITIES = 20;
const W = 460, H = 300, PAD = 22;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let cities = [], tour = [], bestTour = [], bestLen = Infinity;
let animId = null, running = false;
// โโ Utilities โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function dist(a, b) {
const dx = a[0]-b[0], dy = a[1]-b[1];
return Math.sqrt(dx*dx + dy*dy);
}
function tourLength(t) {
let s = 0;
for (let i = 0; i < t.length; i++) s += dist(cities[t[i]], cities[t[(i+1)%t.length]]);
return s;
}
function randInt(lo, hi) { return lo + Math.floor(Math.random() * (hi - lo)); }
// โโ Initialization โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function initCities() {
cities = [];
for (let i = 0; i < N_CITIES; i++)
cities.push([PAD + Math.random()*(W-2*PAD), PAD + Math.random()*(H-2*PAD)]);
tour = Array.from({length: N_CITIES}, (_, i) => i);
// shuffle
for (let i = tour.length-1; i > 0; i--) {
const j = randInt(0, i+1);
[tour[i], tour[j]] = [tour[j], tour[i]];
}
bestTour = tour.slice();
bestLen = tourLength(tour);
}
// โโ Drawing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function draw(t, temperature) {
ctx.clearRect(0, 0, W, H);
// Tour path
const hot = Math.max(0, Math.min(1, temperature / 500));
// Gradient red(hot) -> blue(cold)
const r = Math.round(hot * 200 + (1-hot) * 29);
const g = Math.round(hot * 40 + (1-hot) * 100);
const b = Math.round(hot * 40 + (1-hot) * 200);
ctx.strokeStyle = `rgb(${r},${g},${b})`;
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(cities[t[0]][0], cities[t[0]][1]);
for (let i = 1; i < t.length; i++) ctx.lineTo(cities[t[i]][0], cities[t[i]][1]);
ctx.closePath();
ctx.stroke();
// City dots
for (let i = 0; i < cities.length; i++) {
ctx.beginPath();
ctx.arc(cities[i][0], cities[i][1], 4, 0, Math.PI*2);
ctx.fillStyle = '#1d3557';
ctx.fill();
}
}
function updateUI(len, T) {
document.getElementById('tourLen').textContent = len.toFixed(1);
document.getElementById('bestLen').textContent = bestLen.toFixed(1);
document.getElementById('tempVal').textContent = T < 0.01 ? T.toExponential(1) : T.toFixed(1);
}
// โโ Annealing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function anneal() {
if (running) return;
running = true;
document.getElementById('annealBtn').disabled = true;
document.getElementById('statusMsg').textContent = '';
let T = 800; // initial temperature
const alpha = 0.992; // cooling rate
const stepsPerFrame = 120;
let currentLen = tourLength(tour);
function step() {
for (let s = 0; s < stepsPerFrame; s++) {
// 2-opt: pick two distinct indices i < j, reverse segment [i+1..j]
let i = randInt(0, N_CITIES - 1);
let j = randInt(i + 1, N_CITIES);
if (j - i < 2) { T *= alpha; continue; }
// Delta for reversing tour[i+1..j]
const a = tour[i], b = tour[(i+1) % N_CITIES];
const c = tour[j], d = tour[(j+1) % N_CITIES];
const before = dist(cities[a], cities[b]) + dist(cities[c], cities[d]);
const after = dist(cities[a], cities[c]) + dist(cities[b], cities[d]);
const delta = after - before;
if (delta < 0 || Math.random() < Math.exp(-delta / T)) {
// Accept: reverse the segment
let lo = i + 1, hi = j;
while (lo < hi) { [tour[lo], tour[hi]] = [tour[hi], tour[lo]]; lo++; hi--; }
currentLen += delta;
}
T *= alpha;
}
if (currentLen < bestLen) {
bestLen = currentLen;
bestTour = tour.slice();
}
draw(tour, T);
updateUI(currentLen, T);
if (T > 0.1) {
animId = requestAnimationFrame(step);
} else {
// Show best
tour = bestTour.slice();
currentLen = bestLen;
draw(tour, 0);
updateUI(currentLen, 0);
document.getElementById('statusMsg').textContent = 'โ Done โ best tour shown';
document.getElementById('annealBtn').disabled = false;
running = false;
}
}
animId = requestAnimationFrame(step);
}
function reset() {
if (animId) { cancelAnimationFrame(animId); animId = null; }
running = false;
document.getElementById('annealBtn').disabled = false;
document.getElementById('statusMsg').textContent = '';
initCities();
draw(tour, 800);
updateUI(tourLength(tour), 800);
}
document.getElementById('annealBtn').addEventListener('click', anneal);
document.getElementById('resetBtn').addEventListener('click', reset);
// Boot
initCities();
draw(tour, 800);
updateUI(tourLength(tour), 800);
// Verify core SA logic (runs in node too)
// assert: accepting a worse move when T is high has probability close to 1
const p_high = Math.exp(-1 / 800); // delta=1, T=800 โ p โ 0.9987
const p_low = Math.exp(-1 / 0.1); // delta=1, T=0.1 โ p โ 5e-5
console.assert(p_high > 0.99, fmt('High-T acceptance should be > 0.99, got %p_high%', { p_high: p_high }));
console.assert(p_low < 0.01, fmt('Low-T acceptance should be < 0.01, got %p_low%', { p_low: p_low }));
console.log(fmt('SA acceptance probabilities: T=800 p=%p_high%, T=0.1 p=%toExponential%', { p_high: p_high.toFixed(4), toExponential: p_low.toExponential(2) }));
Watch what happens: at high temperature the tour thrashes wildly, accepting detours that look terrible. As the temperature falls the improvements stabilize, crossing edges untangle, and the final tour is far shorter than the random starting point. Press Anneal again for a different random instance โ the algorithm nearly always finds a good solution, but the path it takes differs every time.
Comments
Loading comments...