Click dots on the grid, in order, to build a simple polygon (no crossing edges). As you go, the demo tallies the interior points i, the boundary points b, and compares i+b/2−1 against the true area computed independently with the shoelace formula.
<p class="hint">Click grid dots, in order, to build a simple polygon (no crossing edges), then press <b>Close polygon</b>. The demo counts interior points $i$ and boundary points $b$, and checks that $i + b/2 - 1$ matches the true area from the shoelace formula.</p>
<svg id="grid" class="grid" viewBox="0 0 320 320"></svg>
<div class="stats">
<div class="stat"><span class="stat-label">Interior points (i)</span><span class="stat-value" id="val-i">0</span></div>
<div class="stat"><span class="stat-label">Boundary points (b)</span><span class="stat-value" id="val-b">0</span></div>
<div class="stat"><span class="stat-label">Pick: i + b/2 − 1</span><span class="stat-value" id="val-pick">–</span></div>
<div class="stat"><span class="stat-label">Shoelace area</span><span class="stat-value" id="val-area">–</span></div>
</div>
<div class="status" id="status">Click dots to place vertices.</div>
<div class="btns">
<button id="close" type="button">Close polygon</button>
<button id="random" type="button">Random shape</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; }
.grid { width: 100%; max-width: 320px; height: auto; aspect-ratio: 1 / 1; display: block;
background: #f7f9fb; border: 1px solid #d7dee5; border-radius: 8px; touch-action: manipulation; }
.dot { fill: #adb1b8; cursor: pointer; }
.dot:hover { fill: #6d7684; }
.dot.vertex { fill: #1d3557; }
.dot.interior { fill: #0a7d33; }
.dot.boundary-extra { fill: #457b9d; }
.poly-line { fill: rgba(29, 53, 87, 0.12); stroke: #1d3557; stroke-width: 2; }
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: .5rem; margin: .6rem 0; }
.stat { background: #eef2f5; border-radius: 8px; padding: .4rem .6rem; display: flex;
justify-content: space-between; align-items: baseline; font-size: .85rem; }
.stat-label { color: #444; }
.stat-value { font: 700 1.05rem ui-monospace, monospace; color: #1d3557; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 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; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Pick’s theorem demo: build a lattice polygon by clicking grid dots, then compare
const N = 8; // an 8x8 grid of lattice points
const PAD = 24, STEP = (320 - 2 * PAD) / (N - 1);
const toXY = (r, c) => [PAD + c * STEP, PAD + r * STEP];
let vertices = []; // array of [row, col] vertices in click order
let closed = false;
function gcd(a, b) { a = Math.abs(a); b = Math.abs(b); while (b) { [a, b] = [b, a % b]; } return a; }
// Shoelace formula: true area of the polygon, independent of Pick’s theorem.
function shoelaceArea(pts) {
let s = 0;
for (let k = 0; k < pts.length; k++) {
const [x1, y1] = pts[k];
const [x2, y2] = pts[(k + 1) % pts.length];
s += x1 * y2 - x2 * y1;
}
return Math.abs(s) / 2;
}
// Boundary lattice points on one edge = gcd(dx, dy); sum over all edges.
function boundaryCount(pts) {
let b = 0;
for (let k = 0; k < pts.length; k++) {
const [x1, y1] = pts[k];
const [x2, y2] = pts[(k + 1) % pts.length];
b += gcd(x2 - x1, y2 - y1);
}
return b;
}
function onSegment(px, py, x1, y1, x2, y2) {
const cross = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1);
if (cross !== 0) return false;
if (px < Math.min(x1, x2) || px > Math.max(x1, x2)) return false;
if (py < Math.min(y1, y2) || py > Math.max(y1, y2)) return false;
return true;
}
function isOnBoundary(px, py, pts) {
for (let k = 0; k < pts.length; k++) {
const [x1, y1] = pts[k];
const [x2, y2] = pts[(k + 1) % pts.length];
if (onSegment(px, py, x1, y1, x2, y2)) return true;
}
return false;
}
function isInsideRay(px, py, pts) {
let inside = false;
for (let a = 0, bIdx = pts.length - 1; a < pts.length; bIdx = a++) {
const [xa, ya] = pts[a], [xb, yb] = pts[bIdx];
const hit = (ya > py) !== (yb > py) && px < (xb - xa) * (py - ya) / (yb - ya) + xa;
if (hit) inside = !inside;
}
return inside;
}
// Scan every lattice point in the grid and classify it as boundary or interior.
function latticeCounts(pts) {
let interior = 0, boundary = 0;
const boundarySet = new Set();
for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) {
if (isOnBoundary(r, c, pts)) { boundary++; boundarySet.add(r + ',' + c); }
else if (isInsideRay(c, r, pts.map(([rr, cc]) => [cc, rr]))) interior++;
}
return { interior, boundary, boundarySet };
}
function segIntersect(p1, p2, p3, p4) {
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const d1 = cross(p3, p4, p1), d2 = cross(p3, p4, p2), d3 = cross(p1, p2, p3), d4 = cross(p1, p2, p4);
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
}
// Reject self-intersecting edges and vertices lying on another edge (degenerate).
function isSimplePolygon(pts) {
const n = pts.length;
if (n < 3) return false;
for (let a = 0; a < n; a++) for (let b = a + 1; b < n; b++) {
if (Math.abs(a - b) <= 1 || (a === 0 && b === n - 1)) continue;
if (segIntersect(pts[a], pts[(a + 1) % n], pts[b], pts[(b + 1) % n])) return false;
}
for (let a = 0; a < n; a++) for (let b = 0; b < n; b++) {
if (a === b) continue;
const bn = (b + 1) % n;
if (bn === a || b === a) continue;
const [px, py] = pts[a], [x1, y1] = pts[b], [x2, y2] = pts[bn];
if (onSegment(px, py, x1, y1, x2, y2)) return false;
}
return shoelaceArea(pts) > 0;
}
function key(r, c) { return r + ',' + c; }
function render() {
const svg = document.getElementById('grid');
svg.innerHTML = '';
const vertKeys = new Set(vertices.map(([r, c]) => key(r, c)));
let counts = { interior: 0, boundary: 0, boundarySet: new Set() };
if (closed && vertices.length >= 3) counts = latticeCounts(vertices);
if (vertices.length >= 2) {
const pathPts = vertices.map(([r, c]) => toXY(r, c).join(',')).join(' ');
const poly = document.createElementNS('http://www.w3.org/2000/svg', closed ? 'polygon' : 'polyline');
poly.setAttribute('points', pathPts);
poly.setAttribute('class', 'poly-line');
if (!closed) poly.setAttribute('fill', 'none');
svg.appendChild(poly);
}
for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) {
const [x, y] = toXY(r, c);
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', x); dot.setAttribute('cy', y); dot.setAttribute('r', 4.5);
const k = key(r, c);
let cls = 'dot';
if (vertKeys.has(k)) cls += ' vertex';
else if (closed && counts.boundarySet.has(k)) cls += ' boundary-extra';
else if (closed && counts.interior >= 0 && isInsideForDot(r, c)) cls += ' interior';
dot.setAttribute('class', cls);
if (!closed) dot.addEventListener('click', () => onDotClick(r, c));
svg.appendChild(dot);
}
function isInsideForDot(r, c) {
if (!closed || vertices.length < 3) return false;
if (counts.boundarySet.has(key(r, c))) return false;
return isInsideRay(c, r, vertices.map(([rr, cc]) => [cc, rr]));
}
document.getElementById('val-i').textContent = closed ? counts.interior : '–';
document.getElementById('val-b').textContent = closed ? counts.boundary : '–';
if (closed && vertices.length >= 3) {
const pick = counts.interior + counts.boundary / 2 - 1;
const area = shoelaceArea(vertices);
document.getElementById('val-pick').textContent = pick;
document.getElementById('val-area').textContent = area;
if (Math.abs(pick - area) < 1e-9) setStatus(i18nFmt('✓ Match! Pick’s formula gives %pick% — exactly the true area.', { pick: pick }), 'ok');
else setStatus('✗ Mismatch — something about the shape broke the assumptions (report this!).', 'bad');
} else {
document.getElementById('val-pick').textContent = '–';
document.getElementById('val-area').textContent = '–';
}
}
function setStatus(t, cls) { const s = document.getElementById('status'); s.textContent = t; s.className = 'status ' + cls; }
function onDotClick(r, c) {
if (closed) return;
const k = key(r, c);
if (vertices.some(([vr, vc]) => key(vr, vc) === k)) return; // ignore clicks on an already-placed vertex
vertices.push([r, c]);
setStatus(i18nFmt('%n% vertex/vertices placed. Keep clicking, or close the polygon.', { n: vertices.length }), '');
render();
}
function closePolygon() {
if (vertices.length < 3) { setStatus('Place at least 3 vertices first.', 'bad'); return; }
if (!isSimplePolygon(vertices)) { setStatus('Those edges cross — pick a non-crossing order.', 'bad'); return; }
closed = true;
render();
}
// Random shape - generate random points, sort by angle around centroid, keep if simple.
function randomShape() {
let pts = null, attempts = 0;
while (!pts && attempts < 200) {
attempts++;
const count = 4 + Math.floor(Math.random() * 4);
const cand = []; const seen = new Set();
while (cand.length < count) {
const r = Math.floor(Math.random() * N), c = Math.floor(Math.random() * N);
const k = key(r, c);
if (seen.has(k)) continue;
seen.add(k); cand.push([r, c]);
}
const cy = cand.reduce((s, p) => s + p[0], 0) / cand.length;
const cx = cand.reduce((s, p) => s + p[1], 0) / cand.length;
cand.sort((a, b) => Math.atan2(a[0] - cy, a[1] - cx) - Math.atan2(b[0] - cy, b[1] - cx));
if (isSimplePolygon(cand)) pts = cand;
}
if (!pts) { setStatus('Could not generate a shape, try again.', 'bad'); return; }
vertices = pts; closed = true;
setStatus('Random polygon generated — the identity still holds.', '');
render();
}
document.getElementById('close').addEventListener('click', closePolygon);
document.getElementById('random').addEventListener('click', randomShape);
document.getElementById('reset').addEventListener('click', () => { vertices = []; closed = false; setStatus('Click dots to place vertices.', ''); render(); });
render();
<p class="hint">Click grid dots, in order, to build a simple polygon (no crossing edges), then press <b>Close polygon</b>. The demo counts interior points $i$ and boundary points $b$, and checks that $i + b/2 - 1$ matches the true area from the shoelace formula.</p>
<svg id="grid" class="grid" viewBox="0 0 320 320"></svg>
<div class="stats">
<div class="stat"><span class="stat-label">Interior points (i)</span><span class="stat-value" id="val-i">0</span></div>
<div class="stat"><span class="stat-label">Boundary points (b)</span><span class="stat-value" id="val-b">0</span></div>
<div class="stat"><span class="stat-label">Pick: i + b/2 − 1</span><span class="stat-value" id="val-pick">–</span></div>
<div class="stat"><span class="stat-label">Shoelace area</span><span class="stat-value" id="val-area">–</span></div>
</div>
<div class="status" id="status">Click dots to place vertices.</div>
<div class="btns">
<button id="close" type="button">Close polygon</button>
<button id="random" type="button">Random shape</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; }
.grid { width: 100%; max-width: 320px; height: auto; aspect-ratio: 1 / 1; display: block;
background: #f7f9fb; border: 1px solid #d7dee5; border-radius: 8px; touch-action: manipulation; }
.dot { fill: #adb1b8; cursor: pointer; }
.dot:hover { fill: #6d7684; }
.dot.vertex { fill: #1d3557; }
.dot.interior { fill: #0a7d33; }
.dot.boundary-extra { fill: #457b9d; }
.poly-line { fill: rgba(29, 53, 87, 0.12); stroke: #1d3557; stroke-width: 2; }
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: .5rem; margin: .6rem 0; }
.stat { background: #eef2f5; border-radius: 8px; padding: .4rem .6rem; display: flex;
justify-content: space-between; align-items: baseline; font-size: .85rem; }
.stat-label { color: #444; }
.stat-value { font: 700 1.05rem ui-monospace, monospace; color: #1d3557; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 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; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Pick’s theorem demo: build a lattice polygon by clicking grid dots, then compare
const N = 8; // an 8x8 grid of lattice points
const PAD = 24, STEP = (320 - 2 * PAD) / (N - 1);
const toXY = (r, c) => [PAD + c * STEP, PAD + r * STEP];
let vertices = []; // array of [row, col] vertices in click order
let closed = false;
function gcd(a, b) { a = Math.abs(a); b = Math.abs(b); while (b) { [a, b] = [b, a % b]; } return a; }
// Shoelace formula: true area of the polygon, independent of Pick’s theorem.
function shoelaceArea(pts) {
let s = 0;
for (let k = 0; k < pts.length; k++) {
const [x1, y1] = pts[k];
const [x2, y2] = pts[(k + 1) % pts.length];
s += x1 * y2 - x2 * y1;
}
return Math.abs(s) / 2;
}
// Boundary lattice points on one edge = gcd(dx, dy); sum over all edges.
function boundaryCount(pts) {
let b = 0;
for (let k = 0; k < pts.length; k++) {
const [x1, y1] = pts[k];
const [x2, y2] = pts[(k + 1) % pts.length];
b += gcd(x2 - x1, y2 - y1);
}
return b;
}
function onSegment(px, py, x1, y1, x2, y2) {
const cross = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1);
if (cross !== 0) return false;
if (px < Math.min(x1, x2) || px > Math.max(x1, x2)) return false;
if (py < Math.min(y1, y2) || py > Math.max(y1, y2)) return false;
return true;
}
function isOnBoundary(px, py, pts) {
for (let k = 0; k < pts.length; k++) {
const [x1, y1] = pts[k];
const [x2, y2] = pts[(k + 1) % pts.length];
if (onSegment(px, py, x1, y1, x2, y2)) return true;
}
return false;
}
function isInsideRay(px, py, pts) {
let inside = false;
for (let a = 0, bIdx = pts.length - 1; a < pts.length; bIdx = a++) {
const [xa, ya] = pts[a], [xb, yb] = pts[bIdx];
const hit = (ya > py) !== (yb > py) && px < (xb - xa) * (py - ya) / (yb - ya) + xa;
if (hit) inside = !inside;
}
return inside;
}
// Scan every lattice point in the grid and classify it as boundary or interior.
function latticeCounts(pts) {
let interior = 0, boundary = 0;
const boundarySet = new Set();
for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) {
if (isOnBoundary(r, c, pts)) { boundary++; boundarySet.add(r + ',' + c); }
else if (isInsideRay(c, r, pts.map(([rr, cc]) => [cc, rr]))) interior++;
}
return { interior, boundary, boundarySet };
}
function segIntersect(p1, p2, p3, p4) {
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const d1 = cross(p3, p4, p1), d2 = cross(p3, p4, p2), d3 = cross(p1, p2, p3), d4 = cross(p1, p2, p4);
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
}
// Reject self-intersecting edges and vertices lying on another edge (degenerate).
function isSimplePolygon(pts) {
const n = pts.length;
if (n < 3) return false;
for (let a = 0; a < n; a++) for (let b = a + 1; b < n; b++) {
if (Math.abs(a - b) <= 1 || (a === 0 && b === n - 1)) continue;
if (segIntersect(pts[a], pts[(a + 1) % n], pts[b], pts[(b + 1) % n])) return false;
}
for (let a = 0; a < n; a++) for (let b = 0; b < n; b++) {
if (a === b) continue;
const bn = (b + 1) % n;
if (bn === a || b === a) continue;
const [px, py] = pts[a], [x1, y1] = pts[b], [x2, y2] = pts[bn];
if (onSegment(px, py, x1, y1, x2, y2)) return false;
}
return shoelaceArea(pts) > 0;
}
function key(r, c) { return r + ',' + c; }
function render() {
const svg = document.getElementById('grid');
svg.innerHTML = '';
const vertKeys = new Set(vertices.map(([r, c]) => key(r, c)));
let counts = { interior: 0, boundary: 0, boundarySet: new Set() };
if (closed && vertices.length >= 3) counts = latticeCounts(vertices);
if (vertices.length >= 2) {
const pathPts = vertices.map(([r, c]) => toXY(r, c).join(',')).join(' ');
const poly = document.createElementNS('http://www.w3.org/2000/svg', closed ? 'polygon' : 'polyline');
poly.setAttribute('points', pathPts);
poly.setAttribute('class', 'poly-line');
if (!closed) poly.setAttribute('fill', 'none');
svg.appendChild(poly);
}
for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) {
const [x, y] = toXY(r, c);
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', x); dot.setAttribute('cy', y); dot.setAttribute('r', 4.5);
const k = key(r, c);
let cls = 'dot';
if (vertKeys.has(k)) cls += ' vertex';
else if (closed && counts.boundarySet.has(k)) cls += ' boundary-extra';
else if (closed && counts.interior >= 0 && isInsideForDot(r, c)) cls += ' interior';
dot.setAttribute('class', cls);
if (!closed) dot.addEventListener('click', () => onDotClick(r, c));
svg.appendChild(dot);
}
function isInsideForDot(r, c) {
if (!closed || vertices.length < 3) return false;
if (counts.boundarySet.has(key(r, c))) return false;
return isInsideRay(c, r, vertices.map(([rr, cc]) => [cc, rr]));
}
document.getElementById('val-i').textContent = closed ? counts.interior : '–';
document.getElementById('val-b').textContent = closed ? counts.boundary : '–';
if (closed && vertices.length >= 3) {
const pick = counts.interior + counts.boundary / 2 - 1;
const area = shoelaceArea(vertices);
document.getElementById('val-pick').textContent = pick;
document.getElementById('val-area').textContent = area;
if (Math.abs(pick - area) < 1e-9) setStatus(i18nFmt('✓ Match! Pick’s formula gives %pick% — exactly the true area.', { pick: pick }), 'ok');
else setStatus('✗ Mismatch — something about the shape broke the assumptions (report this!).', 'bad');
} else {
document.getElementById('val-pick').textContent = '–';
document.getElementById('val-area').textContent = '–';
}
}
function setStatus(t, cls) { const s = document.getElementById('status'); s.textContent = t; s.className = 'status ' + cls; }
function onDotClick(r, c) {
if (closed) return;
const k = key(r, c);
if (vertices.some(([vr, vc]) => key(vr, vc) === k)) return; // ignore clicks on an already-placed vertex
vertices.push([r, c]);
setStatus(i18nFmt('%n% vertex/vertices placed. Keep clicking, or close the polygon.', { n: vertices.length }), '');
render();
}
function closePolygon() {
if (vertices.length < 3) { setStatus('Place at least 3 vertices first.', 'bad'); return; }
if (!isSimplePolygon(vertices)) { setStatus('Those edges cross — pick a non-crossing order.', 'bad'); return; }
closed = true;
render();
}
// Random shape - generate random points, sort by angle around centroid, keep if simple.
function randomShape() {
let pts = null, attempts = 0;
while (!pts && attempts < 200) {
attempts++;
const count = 4 + Math.floor(Math.random() * 4);
const cand = []; const seen = new Set();
while (cand.length < count) {
const r = Math.floor(Math.random() * N), c = Math.floor(Math.random() * N);
const k = key(r, c);
if (seen.has(k)) continue;
seen.add(k); cand.push([r, c]);
}
const cy = cand.reduce((s, p) => s + p[0], 0) / cand.length;
const cx = cand.reduce((s, p) => s + p[1], 0) / cand.length;
cand.sort((a, b) => Math.atan2(a[0] - cy, a[1] - cx) - Math.atan2(b[0] - cy, b[1] - cx));
if (isSimplePolygon(cand)) pts = cand;
}
if (!pts) { setStatus('Could not generate a shape, try again.', 'bad'); return; }
vertices = pts; closed = true;
setStatus('Random polygon generated — the identity still holds.', '');
render();
}
document.getElementById('close').addEventListener('click', closePolygon);
document.getElementById('random').addEventListener('click', randomShape);
document.getElementById('reset').addEventListener('click', () => { vertices = []; closed = false; setStatus('Click dots to place vertices.', ''); render(); });
render();
Try a plain square first, then drag in an oddly shaped polygon. However strange the outline, as long as every vertex lands on a dot, the two numbers always agree. Random shapes picks a fresh random polygon so you can watch the identity hold over and over.
Comments
Loading comments...