Click anywhere on the canvas to drop points. Once you have at least three, press Run Graham scan to watch it work: it finds the lowest point, sorts the rest by angle around it, then walks the sorted order one step at a time, popping the stack every time a right turn shows up.
<p class="hint">Click the canvas to add points (gray). Then run Graham scan: it picks the lowest point as a pivot, sorts every other point by angle around it, and sweeps through, pushing points onto a stack and popping the last one whenever it would create a right turn.</p>
<canvas id="board" class="board" width="560" height="360"></canvas>
<div class="status" id="status">Click to add at least 3 points.</div>
<div class="btns">
<button id="run" type="button">Run Graham scan</button>
<button id="step" type="button">Step</button>
<button id="reset" type="button" class="ghost">Clear</button>
</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 .7rem; line-height: 1.45; }
.board { width: 100%; max-width: 560px; height: auto; aspect-ratio: 560 / 360; background: #f4f6f8;
border: 1px solid #cdd9e3; border-radius: 8px; cursor: crosshair; display: block; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
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; }
button:disabled { opacity: .5; cursor: not-allowed; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const runBtn = document.getElementById('run');
const stepBtn = document.getElementById('step');
const resetBtn = document.getElementById('reset');
let points = []; // all points placed by the user, in click order
let order = []; // points sorted by angle around the pivot (built once, on run/first step)
let stack = []; // the hull-so-far, as a stack of points
let cursor = 0; // index of the next point in `order` to process
let phase = 'idle'; // idle -> running -> done
let lastPop = null; // the point most recently removed from the stack, for highlighting
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = 'status ' + (cls || ''); }
// Cross product sign test: > 0 left turn, < 0 right turn, 0 collinear.
function cross(o, a, b) {
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}
function dist2(a, b) { const dx = a.x - b.x, dy = a.y - b.y; return dx * dx + dy * dy; }
// Pick the lowest-then-leftmost point as pivot, sort the rest by polar angle (ties broken by distance).
function prepareOrder() {
let pivot = points[0];
for (const p of points) {
if (p.y > pivot.y || (p.y === pivot.y && p.x < pivot.x)) pivot = p;
}
const rest = points.filter(p => p !== pivot);
rest.sort((a, b) => {
const c = cross(pivot, a, b);
if (c !== 0) return -c; // sort counter-clockwise around the pivot
return dist2(pivot, a) - dist2(pivot, b);
});
order = [pivot, ...rest];
stack = [];
cursor = 0;
lastPop = null;
}
// Process the next point in angle order: pop every right turn, then push.
function advance() {
if (cursor >= order.length) { phase = 'done'; finish(); return; }
const p = order[cursor];
lastPop = null;
while (stack.length >= 2 && cross(stack[stack.length - 2], stack[stack.length - 1], p) <= 0) {
lastPop = stack.pop();
}
stack.push(p);
cursor++;
draw();
if (lastPop) {
setStatus(i18nFmt('Point #%n% caused a right turn — popped from the stack.', { n: lastPop.i }), '');
} else {
setStatus(i18nFmt('Point #%n% is a left turn — pushed onto the hull stack.', { n: p.i }), '');
}
if (cursor >= order.length) finish();
}
function finish() {
phase = 'done';
setStatus(i18nFmt('Hull closed with %n% point(s) on the boundary.', { n: stack.length }), 'ok');
stepBtn.disabled = true;
runBtn.disabled = true;
draw();
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// draw the current stack as the hull-in-progress
if (stack.length > 1) {
ctx.beginPath();
ctx.moveTo(stack[0].x, stack[0].y);
for (let i = 1; i < stack.length; i++) ctx.lineTo(stack[i].x, stack[i].y);
if (phase === 'done') ctx.closePath();
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
ctx.stroke();
}
// draw every point; highlight the stack and the last popped point
for (const p of points) {
const onStack = stack.includes(p);
ctx.beginPath();
ctx.arc(p.x, p.y, 6, 0, Math.PI * 2);
ctx.fillStyle = p === lastPop ? '#c92f3c' : (onStack ? '#1d3557' : '#8d99ae');
ctx.fill();
}
}
canvas.addEventListener('click', (e) => {
if (phase !== 'idle') return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
points.push({ x, y, i: points.length + 1 });
draw();
setStatus(i18nFmt('%n% point(s) placed.', { n: points.length }), '');
});
runBtn.addEventListener('click', () => {
if (points.length < 3) { setStatus('Add at least 3 points first.', 'bad'); return; }
if (phase === 'idle') { phase = 'running'; prepareOrder(); }
// run every remaining step immediately
while (cursor < order.length) advance();
});
stepBtn.addEventListener('click', () => {
if (points.length < 3) { setStatus('Add at least 3 points first.', 'bad'); return; }
if (phase === 'idle') { phase = 'running'; prepareOrder(); draw();
setStatus('Order fixed by angle. Press Step to sweep one point at a time.', ''); return; }
if (phase === 'running') advance();
});
resetBtn.addEventListener('click', () => {
points = []; order = []; stack = []; cursor = 0; phase = 'idle'; lastPop = null;
runBtn.disabled = false; stepBtn.disabled = false;
draw();
setStatus('Click to add at least 3 points.', '');
});
draw();
<p class="hint">Click the canvas to add points (gray). Then run Graham scan: it picks the lowest point as a pivot, sorts every other point by angle around it, and sweeps through, pushing points onto a stack and popping the last one whenever it would create a right turn.</p>
<canvas id="board" class="board" width="560" height="360"></canvas>
<div class="status" id="status">Click to add at least 3 points.</div>
<div class="btns">
<button id="run" type="button">Run Graham scan</button>
<button id="step" type="button">Step</button>
<button id="reset" type="button" class="ghost">Clear</button>
</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 .7rem; line-height: 1.45; }
.board { width: 100%; max-width: 560px; height: auto; aspect-ratio: 560 / 360; background: #f4f6f8;
border: 1px solid #cdd9e3; border-radius: 8px; cursor: crosshair; display: block; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
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; }
button:disabled { opacity: .5; cursor: not-allowed; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const runBtn = document.getElementById('run');
const stepBtn = document.getElementById('step');
const resetBtn = document.getElementById('reset');
let points = []; // all points placed by the user, in click order
let order = []; // points sorted by angle around the pivot (built once, on run/first step)
let stack = []; // the hull-so-far, as a stack of points
let cursor = 0; // index of the next point in `order` to process
let phase = 'idle'; // idle -> running -> done
let lastPop = null; // the point most recently removed from the stack, for highlighting
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = 'status ' + (cls || ''); }
// Cross product sign test: > 0 left turn, < 0 right turn, 0 collinear.
function cross(o, a, b) {
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}
function dist2(a, b) { const dx = a.x - b.x, dy = a.y - b.y; return dx * dx + dy * dy; }
// Pick the lowest-then-leftmost point as pivot, sort the rest by polar angle (ties broken by distance).
function prepareOrder() {
let pivot = points[0];
for (const p of points) {
if (p.y > pivot.y || (p.y === pivot.y && p.x < pivot.x)) pivot = p;
}
const rest = points.filter(p => p !== pivot);
rest.sort((a, b) => {
const c = cross(pivot, a, b);
if (c !== 0) return -c; // sort counter-clockwise around the pivot
return dist2(pivot, a) - dist2(pivot, b);
});
order = [pivot, ...rest];
stack = [];
cursor = 0;
lastPop = null;
}
// Process the next point in angle order: pop every right turn, then push.
function advance() {
if (cursor >= order.length) { phase = 'done'; finish(); return; }
const p = order[cursor];
lastPop = null;
while (stack.length >= 2 && cross(stack[stack.length - 2], stack[stack.length - 1], p) <= 0) {
lastPop = stack.pop();
}
stack.push(p);
cursor++;
draw();
if (lastPop) {
setStatus(i18nFmt('Point #%n% caused a right turn — popped from the stack.', { n: lastPop.i }), '');
} else {
setStatus(i18nFmt('Point #%n% is a left turn — pushed onto the hull stack.', { n: p.i }), '');
}
if (cursor >= order.length) finish();
}
function finish() {
phase = 'done';
setStatus(i18nFmt('Hull closed with %n% point(s) on the boundary.', { n: stack.length }), 'ok');
stepBtn.disabled = true;
runBtn.disabled = true;
draw();
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// draw the current stack as the hull-in-progress
if (stack.length > 1) {
ctx.beginPath();
ctx.moveTo(stack[0].x, stack[0].y);
for (let i = 1; i < stack.length; i++) ctx.lineTo(stack[i].x, stack[i].y);
if (phase === 'done') ctx.closePath();
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
ctx.stroke();
}
// draw every point; highlight the stack and the last popped point
for (const p of points) {
const onStack = stack.includes(p);
ctx.beginPath();
ctx.arc(p.x, p.y, 6, 0, Math.PI * 2);
ctx.fillStyle = p === lastPop ? '#c92f3c' : (onStack ? '#1d3557' : '#8d99ae');
ctx.fill();
}
}
canvas.addEventListener('click', (e) => {
if (phase !== 'idle') return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
points.push({ x, y, i: points.length + 1 });
draw();
setStatus(i18nFmt('%n% point(s) placed.', { n: points.length }), '');
});
runBtn.addEventListener('click', () => {
if (points.length < 3) { setStatus('Add at least 3 points first.', 'bad'); return; }
if (phase === 'idle') { phase = 'running'; prepareOrder(); }
// run every remaining step immediately
while (cursor < order.length) advance();
});
stepBtn.addEventListener('click', () => {
if (points.length < 3) { setStatus('Add at least 3 points first.', 'bad'); return; }
if (phase === 'idle') { phase = 'running'; prepareOrder(); draw();
setStatus('Order fixed by angle. Press Step to sweep one point at a time.', ''); return; }
if (phase === 'running') advance();
});
resetBtn.addEventListener('click', () => {
points = []; order = []; stack = []; cursor = 0; phase = 'idle'; lastPop = null;
runBtn.disabled = false; stepBtn.disabled = false;
draw();
setStatus('Click to add at least 3 points.', '');
});
draw();
Use Step to go one point at a time and see exactly when a pop happens — that's the moment the algorithm realizes a previously "promising" point was actually tucked inside the hull all along.
Comments
Loading comments...