<div id="app">
<canvas id="canvas" width="320" height="220"></canvas>
<div id="info">
<span id="label-len">Tour length:</span> <strong id="tourLen">โ</strong>
| <span id="label-best">Best:</span> <strong id="bestLen">โ</strong>
| <span id="label-iter">Iterations:</span> <strong id="iterCount">0</strong>
</div>
<div id="history-wrap"><canvas id="history" width="320" height="60"></canvas></div>
<div class="btns">
<button id="btnLS" type="button">Run Local Search</button>
<button id="btnPerturb" type="button">Perturb + Re-optimize</button>
<button id="btnAuto" type="button">Auto-run ILS</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
<div id="phase"></div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
#app { display: flex; flex-direction: column; gap: .45rem; }
canvas { display: block; border-radius: 10px; background: #f0f4f8; }
#history-wrap { }
#history { background: #f8fafc; border-radius: 8px; }
#info { font-size: .85rem; color: #444; }
#phase { font-size: .82rem; font-weight: 600; min-height: 1.3em; color: #1d3557; }
.btns { display: flex; gap: .4rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .4rem .8rem; 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 fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// ILS on a small 8-city TSP instance (fixed city positions for reproducibility).
// Local search = 2-opt; perturbation = double-bridge (4-opt move that 2-opt cannot undo).
// History canvas tracks tour length over ILS iterations.
const CITIES = [
[40,40],[140,30],[260,50],[290,110],[240,180],[130,200],[40,160],[20,100]
];
const N = CITIES.length;
// Euclidean distance
function dist(a, b) {
const dx = CITIES[a][0]-CITIES[b][0], dy = CITIES[a][1]-CITIES[b][1];
return Math.sqrt(dx*dx+dy*dy);
}
function tourLen(t) {
let d=0; for(let i=0;i<N;i++) d+=dist(t[i],t[(i+1)%N]); return d;
}
function randTour() {
const t=[...Array(N).keys()]; for(let i=N-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[t[i],t[j]]=[t[j],t[i]];} return t;
}
// 2-opt local search (full pass until no improvement)
function twoOpt(tour) {
let t=[...tour], improved=true;
while(improved){
improved=false;
for(let i=0;i<N-1;i++) for(let j=i+2;j<N;j++){
if(j===N-1&&i===0) continue;
const d0=dist(t[i],t[i+1])+dist(t[j],t[(j+1)%N]);
const d1=dist(t[i],t[j])+dist(t[i+1],t[(j+1)%N]);
if(d1<d0-1e-9){
let l=i+1,r=j; while(l<r){[t[l],t[r]]=[t[r],t[l]];l++;r--;}
improved=true;
}
}
}
return t;
}
// Double-bridge perturbation: splits tour into 4 segments and reconnects differently.
// This is the canonical perturbation for TSP-ILS (cannot be undone by 2-opt).
function doubleBridge(tour) {
// Pick 4 cut positions in sorted order
const pos = [];
while(pos.length<4){ const p=1+Math.floor(Math.random()*(N-1)); if(!pos.includes(p)) pos.push(p); }
pos.sort((a,b)=>a-b);
const [a,b,c,d] = pos;
// Segments: [0..a-1] A, [a..b-1] B, [b..c-1] C, [c..d-1] D, [d..N-1] E
// Reconnect as A + C + B + D + E (double bridge)
return [
...tour.slice(0,a),
...tour.slice(b,c),
...tour.slice(a,b),
...tour.slice(c,d),
...tour.slice(d)
];
}
// โโ State โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let currentTour, bestTour, bestLen, history, iterCount, autoTimer, phase;
function init() {
currentTour = randTour();
bestTour = [...currentTour];
bestLen = tourLen(currentTour);
history = [bestLen];
iterCount = 0;
phase = 'random';
stopAuto();
render();
updateInfo();
document.getElementById('phase').textContent = 'Start: random tour. Click Run Local Search.';
}
// โโ Drawing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const hCanvas = document.getElementById('history');
const hCtx = hCanvas.getContext('2d');
function drawTour(tour, color) {
ctx.beginPath();
ctx.strokeStyle = color; ctx.lineWidth = 1.8;
ctx.moveTo(CITIES[tour[0]][0], CITIES[tour[0]][1]);
for(let i=1;i<N;i++) ctx.lineTo(CITIES[tour[i]][0], CITIES[tour[i]][1]);
ctx.closePath(); ctx.stroke();
}
function render() {
ctx.clearRect(0,0,canvas.width,canvas.height);
// Draw best tour faintly
if(phase!=='random') drawTour(bestTour, 'rgba(29,53,87,0.18)');
// Draw current tour
const col = phase==='perturbed' ? '#e07000' : phase==='optimized' ? '#0a7d33' : '#1d3557';
drawTour(currentTour, col);
// Draw cities
for(let i=0;i<N;i++){
ctx.beginPath();
ctx.arc(CITIES[i][0],CITIES[i][1],5,0,2*Math.PI);
ctx.fillStyle = '#1d3557'; ctx.fill();
ctx.fillStyle = '#fff'; ctx.font='bold 9px system-ui';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(i+1, CITIES[i][0], CITIES[i][1]);
}
}
function renderHistory() {
const w=hCanvas.width, h=hCanvas.height;
hCtx.clearRect(0,0,w,h);
if(history.length<2) return;
const mn=Math.min(...history)*0.97, mx=Math.max(...history)*1.01;
const pad=8;
hCtx.strokeStyle='#1d3557'; hCtx.lineWidth=1.5;
hCtx.beginPath();
history.forEach((v,i)=>{
const x=pad+(w-2*pad)*i/(history.length-1);
const y=h-pad-(h-2*pad)*(v-mn)/(mx-mn);
i===0?hCtx.moveTo(x,y):hCtx.lineTo(x,y);
});
hCtx.stroke();
// Mark best
const bi=history.indexOf(Math.min(...history));
const bx=pad+(w-2*pad)*bi/(history.length-1);
const by=h-pad-(h-2*pad)*(history[bi]-mn)/(mx-mn);
hCtx.beginPath(); hCtx.arc(bx,by,4,0,2*Math.PI);
hCtx.fillStyle='#0a7d33'; hCtx.fill();
// axis label
hCtx.fillStyle='#888'; hCtx.font='10px system-ui'; hCtx.textAlign='left';
hCtx.fillText('Tour length history', pad, 12);
}
function updateInfo() {
document.getElementById('tourLen').textContent = Math.round(tourLen(currentTour));
document.getElementById('bestLen').textContent = Math.round(bestLen);
document.getElementById('iterCount').textContent = iterCount;
renderHistory();
}
// โโ Actions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function runLS() {
currentTour = twoOpt(currentTour);
const len = tourLen(currentTour);
if(len < bestLen){ bestLen=len; bestTour=[...currentTour]; }
history.push(len);
iterCount++;
phase='optimized';
render(); updateInfo();
document.getElementById('phase').textContent =
fmt('2-opt local search complete. Tour = %round%. Best = %round2%.', { round: Math.round(len), round2: Math.round(bestLen) });
}
function runPerturb() {
// Perturb from best (ILS canonical: always perturb from best or current)
currentTour = doubleBridge([...bestTour]);
const pLen = tourLen(currentTour);
phase='perturbed';
render();
document.getElementById('phase').textContent =
fmt('Double-bridge perturbation applied. Length = %round%. Re-optimizingโฆ', { round: Math.round(pLen) });
// Then immediately re-optimize
setTimeout(()=>{ runLS(); }, 60);
}
// Auto-run ILS loop
let autoRunning=false;
function startAuto() {
if(autoRunning) return;
autoRunning=true;
document.getElementById('btnAuto').textContent='Stop ILS';
document.getElementById('btnLS').disabled=true;
document.getElementById('btnPerturb').disabled=true;
function step(){
if(!autoRunning) return;
currentTour = doubleBridge([...bestTour]);
currentTour = twoOpt(currentTour);
const len = tourLen(currentTour);
if(len < bestLen){ bestLen=len; bestTour=[...currentTour]; }
history.push(len);
iterCount++;
phase = len<bestLen+0.1 ? 'optimized' : 'perturbed';
render(); updateInfo();
document.getElementById('phase').textContent =
fmt('ILS iteration %iterCount%. Tour = %round%. Best = %round2%.', { iterCount: iterCount, round: Math.round(len), round2: Math.round(bestLen) });
if(iterCount < 60) autoTimer=setTimeout(step, 120);
else stopAuto();
}
autoTimer=setTimeout(step,80);
}
function stopAuto() {
autoRunning=false; clearTimeout(autoTimer);
document.getElementById('btnAuto').textContent='Auto-run ILS';
document.getElementById('btnLS').disabled=false;
document.getElementById('btnPerturb').disabled=false;
}
document.getElementById('btnLS').addEventListener('click', runLS);
document.getElementById('btnPerturb').addEventListener('click', runPerturb);
document.getElementById('btnAuto').addEventListener('click', ()=>{ autoRunning?stopAuto():startAuto(); });
document.getElementById('btnReset').addEventListener('click', init);
init();
<div id="app">
<canvas id="canvas" width="320" height="220"></canvas>
<div id="info">
<span id="label-len">Tour length:</span> <strong id="tourLen">โ</strong>
| <span id="label-best">Best:</span> <strong id="bestLen">โ</strong>
| <span id="label-iter">Iterations:</span> <strong id="iterCount">0</strong>
</div>
<div id="history-wrap"><canvas id="history" width="320" height="60"></canvas></div>
<div class="btns">
<button id="btnLS" type="button">Run Local Search</button>
<button id="btnPerturb" type="button">Perturb + Re-optimize</button>
<button id="btnAuto" type="button">Auto-run ILS</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
<div id="phase"></div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
#app { display: flex; flex-direction: column; gap: .45rem; }
canvas { display: block; border-radius: 10px; background: #f0f4f8; }
#history-wrap { }
#history { background: #f8fafc; border-radius: 8px; }
#info { font-size: .85rem; color: #444; }
#phase { font-size: .82rem; font-weight: 600; min-height: 1.3em; color: #1d3557; }
.btns { display: flex; gap: .4rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .4rem .8rem; 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 fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// ILS on a small 8-city TSP instance (fixed city positions for reproducibility).
// Local search = 2-opt; perturbation = double-bridge (4-opt move that 2-opt cannot undo).
// History canvas tracks tour length over ILS iterations.
const CITIES = [
[40,40],[140,30],[260,50],[290,110],[240,180],[130,200],[40,160],[20,100]
];
const N = CITIES.length;
// Euclidean distance
function dist(a, b) {
const dx = CITIES[a][0]-CITIES[b][0], dy = CITIES[a][1]-CITIES[b][1];
return Math.sqrt(dx*dx+dy*dy);
}
function tourLen(t) {
let d=0; for(let i=0;i<N;i++) d+=dist(t[i],t[(i+1)%N]); return d;
}
function randTour() {
const t=[...Array(N).keys()]; for(let i=N-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[t[i],t[j]]=[t[j],t[i]];} return t;
}
// 2-opt local search (full pass until no improvement)
function twoOpt(tour) {
let t=[...tour], improved=true;
while(improved){
improved=false;
for(let i=0;i<N-1;i++) for(let j=i+2;j<N;j++){
if(j===N-1&&i===0) continue;
const d0=dist(t[i],t[i+1])+dist(t[j],t[(j+1)%N]);
const d1=dist(t[i],t[j])+dist(t[i+1],t[(j+1)%N]);
if(d1<d0-1e-9){
let l=i+1,r=j; while(l<r){[t[l],t[r]]=[t[r],t[l]];l++;r--;}
improved=true;
}
}
}
return t;
}
// Double-bridge perturbation: splits tour into 4 segments and reconnects differently.
// This is the canonical perturbation for TSP-ILS (cannot be undone by 2-opt).
function doubleBridge(tour) {
// Pick 4 cut positions in sorted order
const pos = [];
while(pos.length<4){ const p=1+Math.floor(Math.random()*(N-1)); if(!pos.includes(p)) pos.push(p); }
pos.sort((a,b)=>a-b);
const [a,b,c,d] = pos;
// Segments: [0..a-1] A, [a..b-1] B, [b..c-1] C, [c..d-1] D, [d..N-1] E
// Reconnect as A + C + B + D + E (double bridge)
return [
...tour.slice(0,a),
...tour.slice(b,c),
...tour.slice(a,b),
...tour.slice(c,d),
...tour.slice(d)
];
}
// โโ State โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let currentTour, bestTour, bestLen, history, iterCount, autoTimer, phase;
function init() {
currentTour = randTour();
bestTour = [...currentTour];
bestLen = tourLen(currentTour);
history = [bestLen];
iterCount = 0;
phase = 'random';
stopAuto();
render();
updateInfo();
document.getElementById('phase').textContent = 'Start: random tour. Click Run Local Search.';
}
// โโ Drawing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const hCanvas = document.getElementById('history');
const hCtx = hCanvas.getContext('2d');
function drawTour(tour, color) {
ctx.beginPath();
ctx.strokeStyle = color; ctx.lineWidth = 1.8;
ctx.moveTo(CITIES[tour[0]][0], CITIES[tour[0]][1]);
for(let i=1;i<N;i++) ctx.lineTo(CITIES[tour[i]][0], CITIES[tour[i]][1]);
ctx.closePath(); ctx.stroke();
}
function render() {
ctx.clearRect(0,0,canvas.width,canvas.height);
// Draw best tour faintly
if(phase!=='random') drawTour(bestTour, 'rgba(29,53,87,0.18)');
// Draw current tour
const col = phase==='perturbed' ? '#e07000' : phase==='optimized' ? '#0a7d33' : '#1d3557';
drawTour(currentTour, col);
// Draw cities
for(let i=0;i<N;i++){
ctx.beginPath();
ctx.arc(CITIES[i][0],CITIES[i][1],5,0,2*Math.PI);
ctx.fillStyle = '#1d3557'; ctx.fill();
ctx.fillStyle = '#fff'; ctx.font='bold 9px system-ui';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(i+1, CITIES[i][0], CITIES[i][1]);
}
}
function renderHistory() {
const w=hCanvas.width, h=hCanvas.height;
hCtx.clearRect(0,0,w,h);
if(history.length<2) return;
const mn=Math.min(...history)*0.97, mx=Math.max(...history)*1.01;
const pad=8;
hCtx.strokeStyle='#1d3557'; hCtx.lineWidth=1.5;
hCtx.beginPath();
history.forEach((v,i)=>{
const x=pad+(w-2*pad)*i/(history.length-1);
const y=h-pad-(h-2*pad)*(v-mn)/(mx-mn);
i===0?hCtx.moveTo(x,y):hCtx.lineTo(x,y);
});
hCtx.stroke();
// Mark best
const bi=history.indexOf(Math.min(...history));
const bx=pad+(w-2*pad)*bi/(history.length-1);
const by=h-pad-(h-2*pad)*(history[bi]-mn)/(mx-mn);
hCtx.beginPath(); hCtx.arc(bx,by,4,0,2*Math.PI);
hCtx.fillStyle='#0a7d33'; hCtx.fill();
// axis label
hCtx.fillStyle='#888'; hCtx.font='10px system-ui'; hCtx.textAlign='left';
hCtx.fillText('Tour length history', pad, 12);
}
function updateInfo() {
document.getElementById('tourLen').textContent = Math.round(tourLen(currentTour));
document.getElementById('bestLen').textContent = Math.round(bestLen);
document.getElementById('iterCount').textContent = iterCount;
renderHistory();
}
// โโ Actions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function runLS() {
currentTour = twoOpt(currentTour);
const len = tourLen(currentTour);
if(len < bestLen){ bestLen=len; bestTour=[...currentTour]; }
history.push(len);
iterCount++;
phase='optimized';
render(); updateInfo();
document.getElementById('phase').textContent =
fmt('2-opt local search complete. Tour = %round%. Best = %round2%.', { round: Math.round(len), round2: Math.round(bestLen) });
}
function runPerturb() {
// Perturb from best (ILS canonical: always perturb from best or current)
currentTour = doubleBridge([...bestTour]);
const pLen = tourLen(currentTour);
phase='perturbed';
render();
document.getElementById('phase').textContent =
fmt('Double-bridge perturbation applied. Length = %round%. Re-optimizingโฆ', { round: Math.round(pLen) });
// Then immediately re-optimize
setTimeout(()=>{ runLS(); }, 60);
}
// Auto-run ILS loop
let autoRunning=false;
function startAuto() {
if(autoRunning) return;
autoRunning=true;
document.getElementById('btnAuto').textContent='Stop ILS';
document.getElementById('btnLS').disabled=true;
document.getElementById('btnPerturb').disabled=true;
function step(){
if(!autoRunning) return;
currentTour = doubleBridge([...bestTour]);
currentTour = twoOpt(currentTour);
const len = tourLen(currentTour);
if(len < bestLen){ bestLen=len; bestTour=[...currentTour]; }
history.push(len);
iterCount++;
phase = len<bestLen+0.1 ? 'optimized' : 'perturbed';
render(); updateInfo();
document.getElementById('phase').textContent =
fmt('ILS iteration %iterCount%. Tour = %round%. Best = %round2%.', { iterCount: iterCount, round: Math.round(len), round2: Math.round(bestLen) });
if(iterCount < 60) autoTimer=setTimeout(step, 120);
else stopAuto();
}
autoTimer=setTimeout(step,80);
}
function stopAuto() {
autoRunning=false; clearTimeout(autoTimer);
document.getElementById('btnAuto').textContent='Auto-run ILS';
document.getElementById('btnLS').disabled=false;
document.getElementById('btnPerturb').disabled=false;
}
document.getElementById('btnLS').addEventListener('click', runLS);
document.getElementById('btnPerturb').addEventListener('click', runPerturb);
document.getElementById('btnAuto').addEventListener('click', ()=>{ autoRunning?stopAuto():startAuto(); });
document.getElementById('btnReset').addEventListener('click', init);
init();
Comments
Loading comments...