Below is a polygon and a movable dot. A horizontal ray shoots to the right from the dot. Drag the dot around — through the shape, into a concave notch, back out — and watch the crossing counter.
<p class="hint">Drag the <b>blue dot</b> anywhere. A ray shoots to the right and counts how many polygon edges it crosses. <b>Odd</b> crossings → inside, <b>even</b> → outside. Watch the verdict flip as you cross a boundary.</p>
<canvas id="cv" width="360" height="300"></canvas>
<div class="readout">
<span class="pill" id="count">Crossings: 0</span>
<span class="pill verdict" id="verdict">OUTSIDE</span>
</div>
<button id="reset" type="button" class="ghost">Reset point</button>
* { 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 { border: 1px solid #cdd9e3; border-radius: 8px; background: #f7fafc;
touch-action: none; display: block; max-width: 100%; }
.readout { display: flex; gap: .5rem; margin: .7rem 0; flex-wrap: wrap; }
.pill { font: 700 14px ui-monospace, monospace; padding: .4rem .8rem; border-radius: 999px;
background: #e8eef3; color: #1d3557; }
.verdict.in { background: #0a7d33; color: #fff; }
.verdict.out { background: #c92f3c; color: #fff; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #fff; color: #1d3557; border-radius: 8px; cursor: pointer; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Point-in-polygon by ray casting (even-odd rule). We shoot a horizontal ray
// from the test point to +infinity and count how many polygon edges it crosses.
// Odd count => inside, even => outside. This is O(n) per query (PNPOLY, Franklin).
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
// A deliberately concave polygon (an arrow-like notch) to show the rule still holds.
const POLY = [
[60,40],[300,40],[300,140],[180,140],[300,200],[300,260],[60,260],[60,160],[150,150],[60,120]
];
let P = { x: 130, y: 110 };
// Count crossings of the rightward ray from (px,py) with each edge.
// Returns { count, segments } where segments are the edges actually crossed.
function rayCross(px, py) {
let count = 0; const hit = [];
for (let i = 0, n = POLY.length; i < n; i++) {
const [ax, ay] = POLY[i];
const [bx, by] = POLY[(i + 1) % n];
// Edge straddles the horizontal line y = py?
if ((ay > py) !== (by > py)) {
// x of the intersection of the edge with y = py
const xCross = ax + (py - ay) * (bx - ax) / (by - ay);
if (xCross > px) { count++; hit.push(i); }
}
}
return { count, hit };
}
const isInside = (px, py) => rayCross(px, py).count % 2 === 1;
function draw() {
ctx.clearRect(0, 0, cv.width, cv.height);
const { count, hit } = rayCross(P.x, P.y);
const inside = count % 2 === 1;
// polygon
ctx.beginPath();
POLY.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.closePath();
ctx.fillStyle = inside ? 'rgba(10,125,51,.12)' : 'rgba(201,47,60,.08)';
ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = '#1d3557'; ctx.stroke();
// the ray
ctx.beginPath(); ctx.moveTo(P.x, P.y); ctx.lineTo(cv.width, P.y);
ctx.strokeStyle = '#e6a800'; ctx.lineWidth = 2;
ctx.setLineDash([5, 4]); ctx.stroke(); ctx.setLineDash([]);
// crossing markers
hit.forEach(i => {
const [ax, ay] = POLY[i]; const [bx, by] = POLY[(i + 1) % POLY.length];
const xc = ax + (P.y - ay) * (bx - ax) / (by - ay);
ctx.beginPath(); ctx.arc(xc, P.y, 4, 0, 7); ctx.fillStyle = '#e6a800'; ctx.fill();
});
// the point
ctx.beginPath(); ctx.arc(P.x, P.y, 7, 0, 7);
ctx.fillStyle = '#1d6fe6'; ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = '#fff'; ctx.stroke();
// readouts
document.getElementById('count').textContent = fmt('Crossings: %count%', { count: count });
const v = document.getElementById('verdict');
v.textContent = inside ? 'INSIDE' : 'OUTSIDE';
v.className = 'pill verdict ' + (inside ? 'in' : 'out');
}
let dragging = false;
function pos(e) {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { x: (t.clientX - r.left) * cv.width / r.width, y: (t.clientY - r.top) * cv.height / r.height };
}
function down(e) { dragging = true; P = pos(e); draw(); e.preventDefault(); }
function move(e) { if (dragging) { P = pos(e); draw(); e.preventDefault(); } }
function up() { dragging = false; }
cv.addEventListener('mousedown', down); cv.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
cv.addEventListener('touchstart', down); cv.addEventListener('touchmove', move);
window.addEventListener('touchend', up);
document.getElementById('reset').addEventListener('click', () => { P = { x: 130, y: 110 }; draw(); });
draw();
<p class="hint">Drag the <b>blue dot</b> anywhere. A ray shoots to the right and counts how many polygon edges it crosses. <b>Odd</b> crossings → inside, <b>even</b> → outside. Watch the verdict flip as you cross a boundary.</p>
<canvas id="cv" width="360" height="300"></canvas>
<div class="readout">
<span class="pill" id="count">Crossings: 0</span>
<span class="pill verdict" id="verdict">OUTSIDE</span>
</div>
<button id="reset" type="button" class="ghost">Reset point</button>
* { 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 { border: 1px solid #cdd9e3; border-radius: 8px; background: #f7fafc;
touch-action: none; display: block; max-width: 100%; }
.readout { display: flex; gap: .5rem; margin: .7rem 0; flex-wrap: wrap; }
.pill { font: 700 14px ui-monospace, monospace; padding: .4rem .8rem; border-radius: 999px;
background: #e8eef3; color: #1d3557; }
.verdict.in { background: #0a7d33; color: #fff; }
.verdict.out { background: #c92f3c; color: #fff; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #fff; color: #1d3557; border-radius: 8px; cursor: pointer; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Point-in-polygon by ray casting (even-odd rule). We shoot a horizontal ray
// from the test point to +infinity and count how many polygon edges it crosses.
// Odd count => inside, even => outside. This is O(n) per query (PNPOLY, Franklin).
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
// A deliberately concave polygon (an arrow-like notch) to show the rule still holds.
const POLY = [
[60,40],[300,40],[300,140],[180,140],[300,200],[300,260],[60,260],[60,160],[150,150],[60,120]
];
let P = { x: 130, y: 110 };
// Count crossings of the rightward ray from (px,py) with each edge.
// Returns { count, segments } where segments are the edges actually crossed.
function rayCross(px, py) {
let count = 0; const hit = [];
for (let i = 0, n = POLY.length; i < n; i++) {
const [ax, ay] = POLY[i];
const [bx, by] = POLY[(i + 1) % n];
// Edge straddles the horizontal line y = py?
if ((ay > py) !== (by > py)) {
// x of the intersection of the edge with y = py
const xCross = ax + (py - ay) * (bx - ax) / (by - ay);
if (xCross > px) { count++; hit.push(i); }
}
}
return { count, hit };
}
const isInside = (px, py) => rayCross(px, py).count % 2 === 1;
function draw() {
ctx.clearRect(0, 0, cv.width, cv.height);
const { count, hit } = rayCross(P.x, P.y);
const inside = count % 2 === 1;
// polygon
ctx.beginPath();
POLY.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.closePath();
ctx.fillStyle = inside ? 'rgba(10,125,51,.12)' : 'rgba(201,47,60,.08)';
ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = '#1d3557'; ctx.stroke();
// the ray
ctx.beginPath(); ctx.moveTo(P.x, P.y); ctx.lineTo(cv.width, P.y);
ctx.strokeStyle = '#e6a800'; ctx.lineWidth = 2;
ctx.setLineDash([5, 4]); ctx.stroke(); ctx.setLineDash([]);
// crossing markers
hit.forEach(i => {
const [ax, ay] = POLY[i]; const [bx, by] = POLY[(i + 1) % POLY.length];
const xc = ax + (P.y - ay) * (bx - ax) / (by - ay);
ctx.beginPath(); ctx.arc(xc, P.y, 4, 0, 7); ctx.fillStyle = '#e6a800'; ctx.fill();
});
// the point
ctx.beginPath(); ctx.arc(P.x, P.y, 7, 0, 7);
ctx.fillStyle = '#1d6fe6'; ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = '#fff'; ctx.stroke();
// readouts
document.getElementById('count').textContent = fmt('Crossings: %count%', { count: count });
const v = document.getElementById('verdict');
v.textContent = inside ? 'INSIDE' : 'OUTSIDE';
v.className = 'pill verdict ' + (inside ? 'in' : 'out');
}
let dragging = false;
function pos(e) {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { x: (t.clientX - r.left) * cv.width / r.width, y: (t.clientY - r.top) * cv.height / r.height };
}
function down(e) { dragging = true; P = pos(e); draw(); e.preventDefault(); }
function move(e) { if (dragging) { P = pos(e); draw(); e.preventDefault(); } }
function up() { dragging = false; }
cv.addEventListener('mousedown', down); cv.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
cv.addEventListener('touchstart', down); cv.addEventListener('touchmove', move);
window.addEventListener('touchend', up);
document.getElementById('reset').addEventListener('click', () => { P = { x: 130, y: 110 }; draw(); });
draw();
Comments
Loading comments...