Below is a scatter of points, with the leftmost (guaranteed to be on the hull) marked as the starting anchor. Press Step to pivot the wrapping ray one point at a time — watch it sweep counter-clockwise from the current hull edge until it locks onto the most extreme point, exactly like a taut ribbon snapping into place. Press Wrap fully to run the whole march at once, or Reset to scatter a fresh set of points.
<p class="hint">The gold point is the leftmost point — guaranteed to be on the hull, and where the wrap begins. Press <b>Step</b> to pivot one point at a time, or <b>Wrap fully</b> to run the whole march. The dashed red ray shows the candidate edge being tested before it locks in.</p>
<canvas id="cv" width="360" height="280"></canvas>
<div class="status" id="status">Press Step to begin wrapping.</div>
<div class="btns">
<button id="step" type="button">Step</button>
<button id="wrap" type="button">Wrap fully</button>
<button id="reset" type="button" class="ghost">Reset</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; }
canvas { background: #f4f7fa; border: 1px solid #cdd9e3; border-radius: 10px;
touch-action: none; max-width: 100%; display: block; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; color: #1d3557; }
.status.done { color: #0a7d33; }
.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: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Gift wrapping (Jarvis march): start at the leftmost point (guaranteed to be
// on the hull) and repeatedly pivot to whichever remaining point is furthest
// around, using only the sign of a cross product. Cost is O(n*h).
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height, R = 6;
// A fixed base layout jittered randomly each reset, so the hull size h varies.
function scatter(){
const base = [
[60,60],[300,50],[330,210],[70,230],[180,140],
[200,70],[120,180],[260,160],[150,90],[240,210],[40,150]
];
return base.map(([x,y]) => [x + (Math.random()*36-18), y + (Math.random()*36-18)]);
}
let pts = scatter();
// Cross product: positive means c is to the left of the ray a->b.
function cross(o,a,b){ return (a[0]-o[0])*(b[1]-o[1]) - (a[1]-o[1])*(b[0]-o[0]); }
function dist2(a,b){ const dx=a[0]-b[0], dy=a[1]-b[1]; return dx*dx + dy*dy; }
// current = index of the last confirmed hull vertex; candidate = point being tested.
let startIdx = 0, current = 0, hullIdx = [], candidate = -1, finished = false;
function findStart(){
let s = 0;
for(let i=1;i<pts.length;i++){
if(pts[i][0] < pts[s][0] || (pts[i][0]===pts[s][0] && pts[i][1] < pts[s][1])) s = i;
}
return s;
}
function resetWalk(){
startIdx = findStart();
current = startIdx;
hullIdx = [];
candidate = -1;
finished = false;
document.getElementById('wrap').disabled = false;
document.getElementById('step').disabled = false;
}
// One pivot step. From `current`, scan every other point and keep whichever is
// furthest to the left of the ray so far -- that point becomes the next hull vertex.
function pivotOnce(){
if(finished) return false;
hullIdx.push(current);
let next = current === 0 ? (pts.length > 1 ? 1 : 0) : 0;
if(next === current) next = pts.length > 1 ? 1 : 0;
for(let cand = 0; cand < pts.length; cand++){
if(cand === current) continue;
const c = cross(pts[current], pts[next], pts[cand]);
if(c > 0){
next = cand;
} else if(c === 0 && dist2(pts[current], pts[cand]) > dist2(pts[current], pts[next])){
next = cand;
}
}
candidate = next;
current = next;
if(current === startIdx){ finished = true; }
return true;
}
function step(){
if(finished){ return; }
const wasEmpty = hullIdx.length === 0;
pivotOnce();
draw();
if(finished){
setStatus(fmt('Hull closed after %h% pivot(s) — that’s exactly h, the hull size.', { h: hullIdx.length }), 'done');
document.getElementById('step').disabled = true;
document.getElementById('wrap').disabled = true;
} else {
setStatus(fmt('Placed %h% hull point(s) so far — press Step again.', { h: hullIdx.length }), '');
}
}
function wrapFully(){
let guard = 0;
while(!finished && guard < pts.length + 2){ pivotOnce(); guard++; }
draw();
setStatus(fmt('Hull closed after %h% pivot(s) — that’s exactly h, the hull size.', { h: hullIdx.length }), 'done');
document.getElementById('step').disabled = true;
document.getElementById('wrap').disabled = true;
console.log('Final hull vertices, in order:', hullIdx.map(i => pts[i]));
}
function setStatus(t, cls){
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + cls;
}
function draw(){
ctx.clearRect(0,0,W,H);
// Draw the hull edges confirmed so far.
if(hullIdx.length > 1){
ctx.beginPath();
ctx.moveTo(pts[hullIdx[0]][0], pts[hullIdx[0]][1]);
for(let i=1;i<hullIdx.length;i++) ctx.lineTo(pts[hullIdx[i]][0], pts[hullIdx[i]][1]);
if(finished) ctx.closePath();
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2.5;
ctx.stroke();
}
// Draw the current candidate ray being swept before it locks in.
if(!finished && hullIdx.length > 0 && candidate >= 0){
ctx.beginPath();
ctx.moveTo(pts[hullIdx[hullIdx.length-1]][0], pts[hullIdx[hullIdx.length-1]][1]);
ctx.lineTo(pts[candidate][0], pts[candidate][1]);
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.5;
ctx.setLineDash([4,3]);
ctx.stroke();
ctx.setLineDash([]);
}
// Draw every point; hull vertices are larger and dark, the start is gold.
pts.forEach((p, i) => {
const onHull = hullIdx.includes(i);
ctx.beginPath();
ctx.arc(p[0], p[1], onHull ? R+1 : R, 0, Math.PI*2);
ctx.fillStyle = i === startIdx ? '#e9c46a' : (onHull ? '#1d3557' : '#adb5bd');
ctx.fill();
if(onHull || i === startIdx){
ctx.lineWidth = 1.5;
ctx.strokeStyle = '#fff';
ctx.stroke();
}
});
}
document.getElementById('step').addEventListener('click', step);
document.getElementById('wrap').addEventListener('click', wrapFully);
document.getElementById('reset').addEventListener('click', () => {
pts = scatter();
resetWalk();
draw();
setStatus('Press Step to begin wrapping.', '');
});
resetWalk();
draw();
<p class="hint">The gold point is the leftmost point — guaranteed to be on the hull, and where the wrap begins. Press <b>Step</b> to pivot one point at a time, or <b>Wrap fully</b> to run the whole march. The dashed red ray shows the candidate edge being tested before it locks in.</p>
<canvas id="cv" width="360" height="280"></canvas>
<div class="status" id="status">Press Step to begin wrapping.</div>
<div class="btns">
<button id="step" type="button">Step</button>
<button id="wrap" type="button">Wrap fully</button>
<button id="reset" type="button" class="ghost">Reset</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; }
canvas { background: #f4f7fa; border: 1px solid #cdd9e3; border-radius: 10px;
touch-action: none; max-width: 100%; display: block; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; color: #1d3557; }
.status.done { color: #0a7d33; }
.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: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Gift wrapping (Jarvis march): start at the leftmost point (guaranteed to be
// on the hull) and repeatedly pivot to whichever remaining point is furthest
// around, using only the sign of a cross product. Cost is O(n*h).
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height, R = 6;
// A fixed base layout jittered randomly each reset, so the hull size h varies.
function scatter(){
const base = [
[60,60],[300,50],[330,210],[70,230],[180,140],
[200,70],[120,180],[260,160],[150,90],[240,210],[40,150]
];
return base.map(([x,y]) => [x + (Math.random()*36-18), y + (Math.random()*36-18)]);
}
let pts = scatter();
// Cross product: positive means c is to the left of the ray a->b.
function cross(o,a,b){ return (a[0]-o[0])*(b[1]-o[1]) - (a[1]-o[1])*(b[0]-o[0]); }
function dist2(a,b){ const dx=a[0]-b[0], dy=a[1]-b[1]; return dx*dx + dy*dy; }
// current = index of the last confirmed hull vertex; candidate = point being tested.
let startIdx = 0, current = 0, hullIdx = [], candidate = -1, finished = false;
function findStart(){
let s = 0;
for(let i=1;i<pts.length;i++){
if(pts[i][0] < pts[s][0] || (pts[i][0]===pts[s][0] && pts[i][1] < pts[s][1])) s = i;
}
return s;
}
function resetWalk(){
startIdx = findStart();
current = startIdx;
hullIdx = [];
candidate = -1;
finished = false;
document.getElementById('wrap').disabled = false;
document.getElementById('step').disabled = false;
}
// One pivot step. From `current`, scan every other point and keep whichever is
// furthest to the left of the ray so far -- that point becomes the next hull vertex.
function pivotOnce(){
if(finished) return false;
hullIdx.push(current);
let next = current === 0 ? (pts.length > 1 ? 1 : 0) : 0;
if(next === current) next = pts.length > 1 ? 1 : 0;
for(let cand = 0; cand < pts.length; cand++){
if(cand === current) continue;
const c = cross(pts[current], pts[next], pts[cand]);
if(c > 0){
next = cand;
} else if(c === 0 && dist2(pts[current], pts[cand]) > dist2(pts[current], pts[next])){
next = cand;
}
}
candidate = next;
current = next;
if(current === startIdx){ finished = true; }
return true;
}
function step(){
if(finished){ return; }
const wasEmpty = hullIdx.length === 0;
pivotOnce();
draw();
if(finished){
setStatus(fmt('Hull closed after %h% pivot(s) — that’s exactly h, the hull size.', { h: hullIdx.length }), 'done');
document.getElementById('step').disabled = true;
document.getElementById('wrap').disabled = true;
} else {
setStatus(fmt('Placed %h% hull point(s) so far — press Step again.', { h: hullIdx.length }), '');
}
}
function wrapFully(){
let guard = 0;
while(!finished && guard < pts.length + 2){ pivotOnce(); guard++; }
draw();
setStatus(fmt('Hull closed after %h% pivot(s) — that’s exactly h, the hull size.', { h: hullIdx.length }), 'done');
document.getElementById('step').disabled = true;
document.getElementById('wrap').disabled = true;
console.log('Final hull vertices, in order:', hullIdx.map(i => pts[i]));
}
function setStatus(t, cls){
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + cls;
}
function draw(){
ctx.clearRect(0,0,W,H);
// Draw the hull edges confirmed so far.
if(hullIdx.length > 1){
ctx.beginPath();
ctx.moveTo(pts[hullIdx[0]][0], pts[hullIdx[0]][1]);
for(let i=1;i<hullIdx.length;i++) ctx.lineTo(pts[hullIdx[i]][0], pts[hullIdx[i]][1]);
if(finished) ctx.closePath();
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2.5;
ctx.stroke();
}
// Draw the current candidate ray being swept before it locks in.
if(!finished && hullIdx.length > 0 && candidate >= 0){
ctx.beginPath();
ctx.moveTo(pts[hullIdx[hullIdx.length-1]][0], pts[hullIdx[hullIdx.length-1]][1]);
ctx.lineTo(pts[candidate][0], pts[candidate][1]);
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.5;
ctx.setLineDash([4,3]);
ctx.stroke();
ctx.setLineDash([]);
}
// Draw every point; hull vertices are larger and dark, the start is gold.
pts.forEach((p, i) => {
const onHull = hullIdx.includes(i);
ctx.beginPath();
ctx.arc(p[0], p[1], onHull ? R+1 : R, 0, Math.PI*2);
ctx.fillStyle = i === startIdx ? '#e9c46a' : (onHull ? '#1d3557' : '#adb5bd');
ctx.fill();
if(onHull || i === startIdx){
ctx.lineWidth = 1.5;
ctx.strokeStyle = '#fff';
ctx.stroke();
}
});
}
document.getElementById('step').addEventListener('click', step);
document.getElementById('wrap').addEventListener('click', wrapFully);
document.getElementById('reset').addEventListener('click', () => {
pts = scatter();
resetWalk();
draw();
setStatus('Press Step to begin wrapping.', '');
});
resetWalk();
draw();
Notice what the ray is doing at every step: for each candidate point it checks whether that point lies clockwise or counter-clockwise of the current best guess, using nothing more than a cross product (no trigonometry, no sorting). The wrap finishes the moment the ray swings back to the anchor. Count the steps it took — that's exactly the number of vertices on the hull, no more, no less.
Comments
Loading comments...