Drag any vertex of the polygon below. The area and the arrow showing which way it winds — clockwise or counter-clockwise — update instantly, computed straight from the coordinates with the shoelace formula.
<p class="hint">Drag any vertex of the polygon. The shoelace formula recomputes the exact <b>area</b> and <b>winding direction</b> from the raw coordinates alone, every time you move a point.</p>
<svg id="canvas" class="canvas" viewBox="0 0 320 240" xmlns="http://www.w3.org/2000/svg"></svg>
<div class="readout" id="readout">Drag a vertex to begin.</div>
<div class="btns">
<button id="reverse" type="button">Reverse vertex order</button>
<button id="shuffle" type="button">Shuffle vertices</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 .6rem; line-height: 1.45; }
.canvas { width: 100%; max-width: 420px; height: 240px; background: #f4f6f8; border: 1px solid #d7dce1;
border-radius: 10px; touch-action: none; display: block; }
.poly { fill: rgba(29,53,87,0.18); stroke: #1d3557; stroke-width: 2; }
.poly.cw { fill: rgba(230,57,70,0.18); stroke: #c92f3c; }
.vertex { fill: #1d3557; stroke: #fff; stroke-width: 1.5; cursor: grab; }
.vertex.cw { fill: #c92f3c; }
.vertex:active { cursor: grabbing; }
.arrow { stroke: #1d3557; stroke-width: 2; fill: none; marker-end: url(#arrowhead); }
.arrow.cw { stroke: #c92f3c; }
.readout { font: 700 15px ui-monospace, monospace; margin: .55rem 0; min-height: 1.4em; color: #1d3557; }
.readout.cw { 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; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const NS = 'http://www.w3.org/2000/svg';
const svg = document.getElementById('canvas');
const readout = document.getElementById('readout');
// Starting vertices of a simple pentagon, in counter-clockwise screen order.
const START = [[70, 190], [250, 170], [220, 60], [110, 40], [50, 110]];
let pts = START.map((p) => p.slice());
// The shoelace sum: half the sum of x_i * y_(i+1) minus x_(i+1) * y_i over all edges.
function signedArea(p) {
let sum = 0;
for (let i = 0; i < p.length; i++) {
const [x1, y1] = p[i];
const [x2, y2] = p[(i + 1) % p.length];
sum += x1 * y2 - x2 * y1;
}
return sum / 2;
}
function centroid(p) {
const cx = p.reduce((a, b) => a + b[0], 0) / p.length;
const cy = p.reduce((a, b) => a + b[1], 0) / p.length;
return [cx, cy];
}
function el(tag, attrs) {
const e = document.createElementNS(NS, tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
return e;
}
function render() {
svg.innerHTML = '';
const defs = el('defs', {});
const marker = el('marker', { id: 'arrowhead', markerWidth: '8', markerHeight: '8', refX: '6', refY: '3', orient: 'auto' });
marker.appendChild(el('path', { d: 'M0,0 L6,3 L0,6 Z', fill: 'currentColor' }));
defs.appendChild(marker);
svg.appendChild(defs);
// SVG y grows downward, so a negative shoelace sum draws clockwise on screen (flipped from standard math orientation).
const area = signedArea(pts);
const isCW = area < 0; // in SVG's y-down coordinates a negative shoelace sum draws clockwise on screen
const cls = isCW ? 'cw' : '';
const poly = el('polygon', {
points: pts.map((p) => p.join(',')).join(' '),
class: 'poly ' + cls
});
svg.appendChild(poly);
// Draw a curved arrow around the centroid whose direction matches the polygon's winding.
const [cx, cy] = centroid(pts);
const r = 26;
const a0 = -Math.PI / 2;
const sweep = isCW ? 1 : 0;
const a1 = a0 + (isCW ? 1 : -1) * (Math.PI * 1.5);
const x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0);
const x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1);
const large = 1;
const arrow = el('path', {
d: `M ${x0} ${y0} A ${r} ${r} 0 ${large} ${sweep} ${x1} ${y1}`,
class: 'arrow ' + cls
});
svg.appendChild(arrow);
pts.forEach((p, i) => {
const v = el('circle', { cx: p[0], cy: p[1], r: 8, class: 'vertex ' + cls, 'data-i': i });
v.addEventListener('pointerdown', (ev) => startDrag(ev, i));
svg.appendChild(v);
});
const label = isCW ? 'clockwise' : 'counter-clockwise';
readout.textContent = fmt('Area = %area% | winding: %dir%', { area: Math.abs(area).toFixed(1), dir: label });
readout.className = 'readout ' + cls;
}
// Pointer events let the user grab and move a single vertex; coordinates are re-scaled from screen pixels to the SVG viewBox.
let dragIndex = null;
function startDrag(ev, i) {
dragIndex = i;
ev.target.setPointerCapture(ev.pointerId);
}
svg.addEventListener('pointermove', (ev) => {
if (dragIndex === null) return;
const rect = svg.getBoundingClientRect();
const scaleX = 320 / rect.width, scaleY = 240 / rect.height;
let x = (ev.clientX - rect.left) * scaleX;
let y = (ev.clientY - rect.top) * scaleY;
x = Math.max(8, Math.min(312, x));
y = Math.max(8, Math.min(232, y));
pts[dragIndex] = [x, y];
render();
});
svg.addEventListener('pointerup', () => { dragIndex = null; });
svg.addEventListener('pointerleave', () => { dragIndex = null; });
document.getElementById('reverse').addEventListener('click', () => {
pts.reverse();
render();
});
document.getElementById('shuffle').addEventListener('click', () => {
// Nudge every vertex by a small random offset to demonstrate the formula updating live.
const jitter = () => (Math.random() - 0.5) * 40;
pts = pts.map(([x, y]) => [
Math.max(8, Math.min(312, x + jitter())),
Math.max(8, Math.min(232, y + jitter()))
]);
render();
});
document.getElementById('reset').addEventListener('click', () => {
pts = START.map((p) => p.slice());
render();
});
render();
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const NS = 'http://www.w3.org/2000/svg';
const svg = document.getElementById('canvas');
const readout = document.getElementById('readout');
// Starting vertices of a simple pentagon, in counter-clockwise screen order.
const START = [[70, 190], [250, 170], [220, 60], [110, 40], [50, 110]];
let pts = START.map((p) => p.slice());
// The shoelace sum: half the sum of x_i * y_(i+1) minus x_(i+1) * y_i over all edges.
function signedArea(p) {
let sum = 0;
for (let i = 0; i < p.length; i++) {
const [x1, y1] = p[i];
const [x2, y2] = p[(i + 1) % p.length];
sum += x1 * y2 - x2 * y1;
}
return sum / 2;
}
function centroid(p) {
const cx = p.reduce((a, b) => a + b[0], 0) / p.length;
const cy = p.reduce((a, b) => a + b[1], 0) / p.length;
return [cx, cy];
}
function el(tag, attrs) {
const e = document.createElementNS(NS, tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
return e;
}
function render() {
svg.innerHTML = '';
const defs = el('defs', {});
const marker = el('marker', { id: 'arrowhead', markerWidth: '8', markerHeight: '8', refX: '6', refY: '3', orient: 'auto' });
marker.appendChild(el('path', { d: 'M0,0 L6,3 L0,6 Z', fill: 'currentColor' }));
defs.appendChild(marker);
svg.appendChild(defs);
// SVG y grows downward, so a negative shoelace sum draws clockwise on screen (flipped from standard math orientation).
const area = signedArea(pts);
const isCW = area < 0; // in SVG's y-down coordinates a negative shoelace sum draws clockwise on screen
const cls = isCW ? 'cw' : '';
const poly = el('polygon', {
points: pts.map((p) => p.join(',')).join(' '),
class: 'poly ' + cls
});
svg.appendChild(poly);
// Draw a curved arrow around the centroid whose direction matches the polygon's winding.
const [cx, cy] = centroid(pts);
const r = 26;
const a0 = -Math.PI / 2;
const sweep = isCW ? 1 : 0;
const a1 = a0 + (isCW ? 1 : -1) * (Math.PI * 1.5);
const x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0);
const x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1);
const large = 1;
const arrow = el('path', {
d: `M ${x0} ${y0} A ${r} ${r} 0 ${large} ${sweep} ${x1} ${y1}`,
class: 'arrow ' + cls
});
svg.appendChild(arrow);
pts.forEach((p, i) => {
const v = el('circle', { cx: p[0], cy: p[1], r: 8, class: 'vertex ' + cls, 'data-i': i });
v.addEventListener('pointerdown', (ev) => startDrag(ev, i));
svg.appendChild(v);
});
const label = isCW ? 'clockwise' : 'counter-clockwise';
readout.textContent = fmt('Area = %area% | winding: %dir%', { area: Math.abs(area).toFixed(1), dir: label });
readout.className = 'readout ' + cls;
}
// Pointer events let the user grab and move a single vertex; coordinates are re-scaled from screen pixels to the SVG viewBox.
let dragIndex = null;
function startDrag(ev, i) {
dragIndex = i;
ev.target.setPointerCapture(ev.pointerId);
}
svg.addEventListener('pointermove', (ev) => {
if (dragIndex === null) return;
const rect = svg.getBoundingClientRect();
const scaleX = 320 / rect.width, scaleY = 240 / rect.height;
let x = (ev.clientX - rect.left) * scaleX;
let y = (ev.clientY - rect.top) * scaleY;
x = Math.max(8, Math.min(312, x));
y = Math.max(8, Math.min(232, y));
pts[dragIndex] = [x, y];
render();
});
svg.addEventListener('pointerup', () => { dragIndex = null; });
svg.addEventListener('pointerleave', () => { dragIndex = null; });
document.getElementById('reverse').addEventListener('click', () => {
pts.reverse();
render();
});
document.getElementById('shuffle').addEventListener('click', () => {
// Nudge every vertex by a small random offset to demonstrate the formula updating live.
const jitter = () => (Math.random() - 0.5) * 40;
pts = pts.map(([x, y]) => [
Math.max(8, Math.min(312, x + jitter())),
Math.max(8, Math.min(232, y + jitter()))
]);
render();
});
document.getElementById('reset').addEventListener('click', () => {
pts = START.map((p) => p.slice());
render();
});
render();
Watch what happens if you drag a vertex across an edge until the outline crosses itself, or if you relabel the vertices in the opposite order: the magnitude of the area barely changes, but the sign flips. That sign is not a bug — it is the formula telling you the polygon's orientation.
Comments
Loading comments...