The landscape below is a one-dimensional energy function with several local minima and one global minimum. Simulated annealing starts at a random point and walks the landscape, sometimes accepting uphill moves. Adjust the cooling rate α (for geometric cooling) and press Run to watch it search.
<!-- Main layout: controls on top, canvas landscape below, status line -->
<div class="controls">
<label>Cooling rate α = <strong id="alphaVal">0.95</strong>
<input type="range" id="alphaSlider" min="0.80" max="0.999" step="0.001" value="0.95">
</label>
<button id="runBtn" type="button">Run</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<canvas id="canvas" width="560" height="220"></canvas>
<div class="status" id="status">Adjust α then press Run.</div>
<div class="legend">
<span class="dot landscape"></span> Energy landscape
<span class="dot dot-current"></span> Current position
<span class="dot dot-best"></span> Best found
</div>
/* Base reset + layout */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; background: transparent; }
.controls { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; margin-bottom: .6rem; }
label { font-size: .88rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 140px; accent-color: #1d3557; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
canvas { display: block; border-radius: 8px; background: #f4f7fa; max-width: 100%; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .5rem 0 .3rem; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.running { color: #1d6fa8; }
.legend { display: flex; gap: 1rem; font-size: .82rem; align-items: center; flex-wrap: wrap; }
.dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; vertical-align: middle; }
.landscape { background: #a8c0d6; }
.dot-current { background: #e63946; }
.dot-best { background: #2a9d8f; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Energy landscape: a rugged 1-D function with several local minima and
// one global minimum, so the effect of the schedule is easy to see.
const W = 560, H = 220, PAD = 20;
const DOMAIN = [0, 10];
function energy(x) {
// Composite sine landscape; global min near x=4.8
return Math.sin(x) + 0.5 * Math.sin(2.2 * x + 0.8) + 0.3 * Math.sin(4 * x - 1) + 0.15 * Math.sin(7 * x);
}
// Pre-compute global minimum by brute-force scan
let globalMinX = 0, globalMinE = Infinity;
for (let i = 0; i <= 2000; i++) {
const x = DOMAIN[0] + (DOMAIN[1] - DOMAIN[0]) * i / 2000;
const e = energy(x); if (e < globalMinE) { globalMinE = e; globalMinX = x; }
}
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Map domain coordinates to canvas pixels
function toPixelX(x) { return PAD + (x - DOMAIN[0]) / (DOMAIN[1] - DOMAIN[0]) * (W - 2 * PAD); }
function toPixelY(e) { return H - PAD - ((e - (-2.5)) / (2.5 - (-2.5))) * (H - 2 * PAD); }
function drawLandscape(curX, bestX) {
ctx.clearRect(0, 0, W, H);
// Draw the energy curve
ctx.beginPath();
for (let i = 0; i <= 500; i++) {
const x = DOMAIN[0] + (DOMAIN[1] - DOMAIN[0]) * i / 500;
const px = toPixelX(x), py = toPixelY(energy(x));
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.strokeStyle = '#a8c0d6'; ctx.lineWidth = 2.5; ctx.stroke();
// Draw best position (teal)
if (bestX !== null) {
const bx = toPixelX(bestX), by = toPixelY(energy(bestX));
ctx.beginPath(); ctx.arc(bx, by, 6, 0, 2 * Math.PI);
ctx.fillStyle = '#2a9d8f'; ctx.fill();
}
// Draw current position (red)
if (curX !== null) {
const cx2 = toPixelX(curX), cy2 = toPixelY(energy(curX));
ctx.beginPath(); ctx.arc(cx2, cy2, 5, 0, 2 * Math.PI);
ctx.fillStyle = '#e63946'; ctx.fill();
}
}
drawLandscape(null, null);
let running = false, animId = null;
function setStatus(txt, cls) {
const el = document.getElementById('status');
el.textContent = txt; el.className = 'status ' + (cls || '');
}
document.getElementById('alphaSlider').addEventListener('input', function() {
document.getElementById('alphaVal').textContent = parseFloat(this.value).toFixed(3);
});
document.getElementById('runBtn').addEventListener('click', function() {
if (running) return;
running = true;
if (animId) cancelAnimationFrame(animId);
setStatus('Searching...', 'running');
const alpha = parseFloat(document.getElementById('alphaSlider').value);
// SA params: start hot, cool by alpha each step
let T = 2.0;
const Tmin = 0.001;
let x = DOMAIN[0] + Math.random() * (DOMAIN[1] - DOMAIN[0]);
let bestX = x, bestE = energy(x);
let steps = 0;
const BATCH = 40; // steps per animation frame
function step() {
if (!running) return;
for (let b = 0; b < BATCH; b++) {
if (T < Tmin) { finish(); return; }
// Propose a random neighbour within ±0.4 of current x
const dx = (Math.random() - 0.5) * 0.8;
const nx = Math.max(DOMAIN[0], Math.min(DOMAIN[1], x + dx));
const dE = energy(nx) - energy(x);
// Metropolis acceptance: always accept improvements, accept worse with prob e^(-dE/T)
if (dE < 0 || Math.random() < Math.exp(-dE / T)) { x = nx; }
if (energy(x) < bestE) { bestE = energy(x); bestX = x; }
T *= alpha; steps++;
}
drawLandscape(x, bestX);
animId = requestAnimationFrame(step);
}
function finish() {
running = false;
drawLandscape(bestX, bestX);
const gap = Math.abs(bestE - globalMinE);
const cls = gap < 0.15 ? 'ok' : 'bad';
setStatus(i18nFmt('Done in %steps% steps — gap from global min: %gap%', { steps: steps, gap: gap.toFixed(3) }), cls);
}
step();
});
document.getElementById('resetBtn').addEventListener('click', function() {
running = false;
if (animId) cancelAnimationFrame(animId);
drawLandscape(null, null);
setStatus('Adjust α then press Run.', '');
});
<!-- Main layout: controls on top, canvas landscape below, status line -->
<div class="controls">
<label>Cooling rate α = <strong id="alphaVal">0.95</strong>
<input type="range" id="alphaSlider" min="0.80" max="0.999" step="0.001" value="0.95">
</label>
<button id="runBtn" type="button">Run</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<canvas id="canvas" width="560" height="220"></canvas>
<div class="status" id="status">Adjust α then press Run.</div>
<div class="legend">
<span class="dot landscape"></span> Energy landscape
<span class="dot dot-current"></span> Current position
<span class="dot dot-best"></span> Best found
</div>
/* Base reset + layout */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; background: transparent; }
.controls { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; margin-bottom: .6rem; }
label { font-size: .88rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 140px; accent-color: #1d3557; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
canvas { display: block; border-radius: 8px; background: #f4f7fa; max-width: 100%; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .5rem 0 .3rem; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.running { color: #1d6fa8; }
.legend { display: flex; gap: 1rem; font-size: .82rem; align-items: center; flex-wrap: wrap; }
.dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; vertical-align: middle; }
.landscape { background: #a8c0d6; }
.dot-current { background: #e63946; }
.dot-best { background: #2a9d8f; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Energy landscape: a rugged 1-D function with several local minima and
// one global minimum, so the effect of the schedule is easy to see.
const W = 560, H = 220, PAD = 20;
const DOMAIN = [0, 10];
function energy(x) {
// Composite sine landscape; global min near x=4.8
return Math.sin(x) + 0.5 * Math.sin(2.2 * x + 0.8) + 0.3 * Math.sin(4 * x - 1) + 0.15 * Math.sin(7 * x);
}
// Pre-compute global minimum by brute-force scan
let globalMinX = 0, globalMinE = Infinity;
for (let i = 0; i <= 2000; i++) {
const x = DOMAIN[0] + (DOMAIN[1] - DOMAIN[0]) * i / 2000;
const e = energy(x); if (e < globalMinE) { globalMinE = e; globalMinX = x; }
}
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Map domain coordinates to canvas pixels
function toPixelX(x) { return PAD + (x - DOMAIN[0]) / (DOMAIN[1] - DOMAIN[0]) * (W - 2 * PAD); }
function toPixelY(e) { return H - PAD - ((e - (-2.5)) / (2.5 - (-2.5))) * (H - 2 * PAD); }
function drawLandscape(curX, bestX) {
ctx.clearRect(0, 0, W, H);
// Draw the energy curve
ctx.beginPath();
for (let i = 0; i <= 500; i++) {
const x = DOMAIN[0] + (DOMAIN[1] - DOMAIN[0]) * i / 500;
const px = toPixelX(x), py = toPixelY(energy(x));
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.strokeStyle = '#a8c0d6'; ctx.lineWidth = 2.5; ctx.stroke();
// Draw best position (teal)
if (bestX !== null) {
const bx = toPixelX(bestX), by = toPixelY(energy(bestX));
ctx.beginPath(); ctx.arc(bx, by, 6, 0, 2 * Math.PI);
ctx.fillStyle = '#2a9d8f'; ctx.fill();
}
// Draw current position (red)
if (curX !== null) {
const cx2 = toPixelX(curX), cy2 = toPixelY(energy(curX));
ctx.beginPath(); ctx.arc(cx2, cy2, 5, 0, 2 * Math.PI);
ctx.fillStyle = '#e63946'; ctx.fill();
}
}
drawLandscape(null, null);
let running = false, animId = null;
function setStatus(txt, cls) {
const el = document.getElementById('status');
el.textContent = txt; el.className = 'status ' + (cls || '');
}
document.getElementById('alphaSlider').addEventListener('input', function() {
document.getElementById('alphaVal').textContent = parseFloat(this.value).toFixed(3);
});
document.getElementById('runBtn').addEventListener('click', function() {
if (running) return;
running = true;
if (animId) cancelAnimationFrame(animId);
setStatus('Searching...', 'running');
const alpha = parseFloat(document.getElementById('alphaSlider').value);
// SA params: start hot, cool by alpha each step
let T = 2.0;
const Tmin = 0.001;
let x = DOMAIN[0] + Math.random() * (DOMAIN[1] - DOMAIN[0]);
let bestX = x, bestE = energy(x);
let steps = 0;
const BATCH = 40; // steps per animation frame
function step() {
if (!running) return;
for (let b = 0; b < BATCH; b++) {
if (T < Tmin) { finish(); return; }
// Propose a random neighbour within ±0.4 of current x
const dx = (Math.random() - 0.5) * 0.8;
const nx = Math.max(DOMAIN[0], Math.min(DOMAIN[1], x + dx));
const dE = energy(nx) - energy(x);
// Metropolis acceptance: always accept improvements, accept worse with prob e^(-dE/T)
if (dE < 0 || Math.random() < Math.exp(-dE / T)) { x = nx; }
if (energy(x) < bestE) { bestE = energy(x); bestX = x; }
T *= alpha; steps++;
}
drawLandscape(x, bestX);
animId = requestAnimationFrame(step);
}
function finish() {
running = false;
drawLandscape(bestX, bestX);
const gap = Math.abs(bestE - globalMinE);
const cls = gap < 0.15 ? 'ok' : 'bad';
setStatus(i18nFmt('Done in %steps% steps — gap from global min: %gap%', { steps: steps, gap: gap.toFixed(3) }), cls);
}
step();
});
document.getElementById('resetBtn').addEventListener('click', function() {
running = false;
if (animId) cancelAnimationFrame(animId);
drawLandscape(null, null);
setStatus('Adjust α then press Run.', '');
});
With a fast cool (α near 0.8) the algorithm quickly freezes wherever it lands — often a local minimum. With a slow cool (α near 0.999) it explores broadly and almost always finds the global minimum, but takes far more steps. The sweet spot for a given problem is what schedule design is all about.
Comments
Loading comments...