Below is a figure-eight-style polygon — one loop wound one way, the other wound the opposite way, crossing itself in the middle. A movable dot sits on the canvas. Drag it around and watch the accumulated turning angle update live as it sums the signed angle to every edge.
<p class="hint">Two loops share the canvas — the right one traced one way, the left one traced the opposite way, meeting nowhere so each stays a separate closed path. Drag the red dot and watch the dashed lines to every corner sweep as the boundary is walked.</p>
<div class="stage" id="stage">
<svg id="scene" viewBox="0 0 400 260" xmlns="http://www.w3.org/2000/svg"></svg>
</div>
<div class="readout">
<div class="pill"><span class="label">Total angle</span><span id="angleVal">0°</span></div>
<div class="pill"><span class="label">Winding #</span><span id="windingVal">0</span></div>
<div class="pill" id="verdictPill"><span id="verdictVal">Outside (winds 0 times)</span></div>
</div>
<div class="btns">
<button id="animate" type="button">Animate a sweep</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; }
.stage { border: 1px solid #cdd9e3; border-radius: 10px; background: #f7f9fb; touch-action: none; }
svg { display: block; width: 100%; height: auto; cursor: grab; }
.loop { fill: rgba(29,53,87,0.06); stroke: #1d3557; stroke-width: 2; }
.loop.right { stroke: #1d3557; }
.loop.left { stroke: #7b2cbf; }
.arrowhead { fill: #1d3557; }
.arrowhead.left { fill: #7b2cbf; }
.radial { stroke: #e63946; stroke-width: 1.2; stroke-dasharray: 3 3; opacity: .85; }
.point { fill: #e63946; stroke: #fff; stroke-width: 1.5; cursor: grab; }
.readout { display: flex; gap: .5rem; flex-wrap: wrap; margin: .6rem 0; }
.pill { background: #e8eef3; border: 1px solid #cdd9e3; border-radius: 999px; padding: .3rem .8rem;
font: 600 13px ui-monospace, monospace; color: #1d3557; display: flex; gap: .4rem; align-items: center; }
.pill .label { font: 600 11px system-ui, sans-serif; color: #5a6b7a; text-transform: uppercase; letter-spacing: .03em; }
#verdictPill { font: 700 13px system-ui, sans-serif; text-transform: none; }
#verdictPill.inside { background: #d9f2e3; border-color: #9fd8b4; color: #0a7d33; }
#verdictPill.outside { background: #f3e8ea; border-color: #e3c3ca; color: #6b6b6b; }
.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] ?? ''));
// Winding-number point-in-polygon demo: sum the signed angle from the query point to every edge of every loop; divide by 360 deg and round to get the winding number.
// Two independent closed loops forming a figure-eight-like pair. RIGHT_LOOP is traced clockwise on screen, LEFT_LOOP counter-clockwise -- opposite orientations on purpose.
const RIGHT_LOOP = [[220,60],[320,60],[320,160],[220,160]];
const LEFT_LOOP = [[80,60],[80,160],[180,160],[180,60]]; // reversed order vs RIGHT_LOOP so its winding sign flips
const PATHS = [RIGHT_LOOP, LEFT_LOOP];
const svg = document.getElementById('scene');
const NS = 'http://www.w3.org/2000/svg';
function makeLoopPath(poly, cls) {
const d = poly.map((p, i) => (i === 0 ? 'M' : 'L') + p[0] + ',' + p[1]).join(' ') + ' Z';
const el = document.createElementNS(NS, 'path');
el.setAttribute('d', d);
el.setAttribute('class', 'loop ' + cls);
return el;
}
// Signed angle subtended at (px,py) by the directed edge (x1,y1)->(x2,y2), normalized to (-pi, pi].
function signedAngle(px, py, x1, y1, x2, y2) {
const a1 = Math.atan2(y1 - py, x1 - px);
const a2 = Math.atan2(y2 - py, x2 - px);
let da = a2 - a1;
while (da > Math.PI) da -= 2 * Math.PI;
while (da < -Math.PI) da += 2 * Math.PI;
return da;
}
// Sum of signed angles around one closed loop = winding number of that loop times 2*pi.
function windingNumberOf(px, py, poly) {
let sum = 0;
for (let i = 0; i < poly.length; i++) {
const [x1, y1] = poly[i];
const [x2, y2] = poly[(i + 1) % poly.length];
sum += signedAngle(px, py, x1, y1, x2, y2);
}
return sum;
}
function totalAngleAndWinding(px, py) {
let totalRad = 0;
for (const poly of PATHS) totalRad += windingNumberOf(px, py, poly);
const deg = totalRad * 180 / Math.PI;
const w = Math.round(totalRad / (2 * Math.PI));
return { deg, w };
}
let point = { x: 270, y: 110 };
let dragging = false;
let animId = null;
function render() {
svg.innerHTML = '';
svg.appendChild(makeLoopPath(RIGHT_LOOP, 'right'));
svg.appendChild(makeLoopPath(LEFT_LOOP, 'left'));
// Draw a faint line from the query point to every vertex, purely to visualize the sweep.
for (const poly of PATHS) {
for (const [x, y] of poly) {
const line = document.createElementNS(NS, 'line');
line.setAttribute('x1', point.x); line.setAttribute('y1', point.y);
line.setAttribute('x2', x); line.setAttribute('y2', y);
line.setAttribute('class', 'radial');
svg.appendChild(line);
}
}
const dot = document.createElementNS(NS, 'circle');
dot.setAttribute('cx', point.x); dot.setAttribute('cy', point.y);
dot.setAttribute('r', 7);
dot.setAttribute('class', 'point');
svg.appendChild(dot);
const { deg, w } = totalAngleAndWinding(point.x, point.y);
document.getElementById('angleVal').textContent = Math.round(deg) + '°';
document.getElementById('windingVal').textContent = w;
const pill = document.getElementById('verdictPill');
const label = document.getElementById('verdictVal');
if (w !== 0) {
pill.className = 'inside';
label.textContent = fmt('Inside (winds %w% time(s))', { w: w });
} else {
pill.className = 'outside';
label.textContent = 'Outside (winds 0 times)';
}
}
function svgPoint(evt) {
const rect = svg.getBoundingClientRect();
const vb = svg.viewBox.baseVal;
const clientX = evt.touches ? evt.touches[0].clientX : evt.clientX;
const clientY = evt.touches ? evt.touches[0].clientY : evt.clientY;
const x = (clientX - rect.left) / rect.width * vb.width;
const y = (clientY - rect.top) / rect.height * vb.height;
return { x, y };
}
function startDrag(evt) { dragging = true; moveDrag(evt); }
function moveDrag(evt) {
if (!dragging) return;
const p = svgPoint(evt);
point.x = Math.max(4, Math.min(396, p.x));
point.y = Math.max(4, Math.min(256, p.y));
render();
}
function endDrag() { dragging = false; }
svg.addEventListener('mousedown', startDrag);
svg.addEventListener('mousemove', moveDrag);
window.addEventListener('mouseup', endDrag);
svg.addEventListener('touchstart', startDrag);
svg.addEventListener('touchmove', moveDrag);
window.addEventListener('touchend', endDrag);
// Animate the point along a sweeping path so the angle and winding number visibly update.
function animate() {
if (animId) { cancelAnimationFrame(animId); animId = null; }
let t = 0;
const cx = 250, cy = 190, r = 170;
function step() {
t += 0.012;
point.x = cx + r * Math.cos(t);
point.y = 110 + 90 * Math.sin(t * 0.7);
render();
if (t < Math.PI * 2) animId = requestAnimationFrame(step);
else animId = null;
}
step();
}
document.getElementById('animate').addEventListener('click', animate);
document.getElementById('reset').addEventListener('click', () => {
if (animId) { cancelAnimationFrame(animId); animId = null; }
point = { x: 270, y: 110 };
render();
});
render();
<p class="hint">Two loops share the canvas — the right one traced one way, the left one traced the opposite way, meeting nowhere so each stays a separate closed path. Drag the red dot and watch the dashed lines to every corner sweep as the boundary is walked.</p>
<div class="stage" id="stage">
<svg id="scene" viewBox="0 0 400 260" xmlns="http://www.w3.org/2000/svg"></svg>
</div>
<div class="readout">
<div class="pill"><span class="label">Total angle</span><span id="angleVal">0°</span></div>
<div class="pill"><span class="label">Winding #</span><span id="windingVal">0</span></div>
<div class="pill" id="verdictPill"><span id="verdictVal">Outside (winds 0 times)</span></div>
</div>
<div class="btns">
<button id="animate" type="button">Animate a sweep</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; }
.stage { border: 1px solid #cdd9e3; border-radius: 10px; background: #f7f9fb; touch-action: none; }
svg { display: block; width: 100%; height: auto; cursor: grab; }
.loop { fill: rgba(29,53,87,0.06); stroke: #1d3557; stroke-width: 2; }
.loop.right { stroke: #1d3557; }
.loop.left { stroke: #7b2cbf; }
.arrowhead { fill: #1d3557; }
.arrowhead.left { fill: #7b2cbf; }
.radial { stroke: #e63946; stroke-width: 1.2; stroke-dasharray: 3 3; opacity: .85; }
.point { fill: #e63946; stroke: #fff; stroke-width: 1.5; cursor: grab; }
.readout { display: flex; gap: .5rem; flex-wrap: wrap; margin: .6rem 0; }
.pill { background: #e8eef3; border: 1px solid #cdd9e3; border-radius: 999px; padding: .3rem .8rem;
font: 600 13px ui-monospace, monospace; color: #1d3557; display: flex; gap: .4rem; align-items: center; }
.pill .label { font: 600 11px system-ui, sans-serif; color: #5a6b7a; text-transform: uppercase; letter-spacing: .03em; }
#verdictPill { font: 700 13px system-ui, sans-serif; text-transform: none; }
#verdictPill.inside { background: #d9f2e3; border-color: #9fd8b4; color: #0a7d33; }
#verdictPill.outside { background: #f3e8ea; border-color: #e3c3ca; color: #6b6b6b; }
.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] ?? ''));
// Winding-number point-in-polygon demo: sum the signed angle from the query point to every edge of every loop; divide by 360 deg and round to get the winding number.
// Two independent closed loops forming a figure-eight-like pair. RIGHT_LOOP is traced clockwise on screen, LEFT_LOOP counter-clockwise -- opposite orientations on purpose.
const RIGHT_LOOP = [[220,60],[320,60],[320,160],[220,160]];
const LEFT_LOOP = [[80,60],[80,160],[180,160],[180,60]]; // reversed order vs RIGHT_LOOP so its winding sign flips
const PATHS = [RIGHT_LOOP, LEFT_LOOP];
const svg = document.getElementById('scene');
const NS = 'http://www.w3.org/2000/svg';
function makeLoopPath(poly, cls) {
const d = poly.map((p, i) => (i === 0 ? 'M' : 'L') + p[0] + ',' + p[1]).join(' ') + ' Z';
const el = document.createElementNS(NS, 'path');
el.setAttribute('d', d);
el.setAttribute('class', 'loop ' + cls);
return el;
}
// Signed angle subtended at (px,py) by the directed edge (x1,y1)->(x2,y2), normalized to (-pi, pi].
function signedAngle(px, py, x1, y1, x2, y2) {
const a1 = Math.atan2(y1 - py, x1 - px);
const a2 = Math.atan2(y2 - py, x2 - px);
let da = a2 - a1;
while (da > Math.PI) da -= 2 * Math.PI;
while (da < -Math.PI) da += 2 * Math.PI;
return da;
}
// Sum of signed angles around one closed loop = winding number of that loop times 2*pi.
function windingNumberOf(px, py, poly) {
let sum = 0;
for (let i = 0; i < poly.length; i++) {
const [x1, y1] = poly[i];
const [x2, y2] = poly[(i + 1) % poly.length];
sum += signedAngle(px, py, x1, y1, x2, y2);
}
return sum;
}
function totalAngleAndWinding(px, py) {
let totalRad = 0;
for (const poly of PATHS) totalRad += windingNumberOf(px, py, poly);
const deg = totalRad * 180 / Math.PI;
const w = Math.round(totalRad / (2 * Math.PI));
return { deg, w };
}
let point = { x: 270, y: 110 };
let dragging = false;
let animId = null;
function render() {
svg.innerHTML = '';
svg.appendChild(makeLoopPath(RIGHT_LOOP, 'right'));
svg.appendChild(makeLoopPath(LEFT_LOOP, 'left'));
// Draw a faint line from the query point to every vertex, purely to visualize the sweep.
for (const poly of PATHS) {
for (const [x, y] of poly) {
const line = document.createElementNS(NS, 'line');
line.setAttribute('x1', point.x); line.setAttribute('y1', point.y);
line.setAttribute('x2', x); line.setAttribute('y2', y);
line.setAttribute('class', 'radial');
svg.appendChild(line);
}
}
const dot = document.createElementNS(NS, 'circle');
dot.setAttribute('cx', point.x); dot.setAttribute('cy', point.y);
dot.setAttribute('r', 7);
dot.setAttribute('class', 'point');
svg.appendChild(dot);
const { deg, w } = totalAngleAndWinding(point.x, point.y);
document.getElementById('angleVal').textContent = Math.round(deg) + '°';
document.getElementById('windingVal').textContent = w;
const pill = document.getElementById('verdictPill');
const label = document.getElementById('verdictVal');
if (w !== 0) {
pill.className = 'inside';
label.textContent = fmt('Inside (winds %w% time(s))', { w: w });
} else {
pill.className = 'outside';
label.textContent = 'Outside (winds 0 times)';
}
}
function svgPoint(evt) {
const rect = svg.getBoundingClientRect();
const vb = svg.viewBox.baseVal;
const clientX = evt.touches ? evt.touches[0].clientX : evt.clientX;
const clientY = evt.touches ? evt.touches[0].clientY : evt.clientY;
const x = (clientX - rect.left) / rect.width * vb.width;
const y = (clientY - rect.top) / rect.height * vb.height;
return { x, y };
}
function startDrag(evt) { dragging = true; moveDrag(evt); }
function moveDrag(evt) {
if (!dragging) return;
const p = svgPoint(evt);
point.x = Math.max(4, Math.min(396, p.x));
point.y = Math.max(4, Math.min(256, p.y));
render();
}
function endDrag() { dragging = false; }
svg.addEventListener('mousedown', startDrag);
svg.addEventListener('mousemove', moveDrag);
window.addEventListener('mouseup', endDrag);
svg.addEventListener('touchstart', startDrag);
svg.addEventListener('touchmove', moveDrag);
window.addEventListener('touchend', endDrag);
// Animate the point along a sweeping path so the angle and winding number visibly update.
function animate() {
if (animId) { cancelAnimationFrame(animId); animId = null; }
let t = 0;
const cx = 250, cy = 190, r = 170;
function step() {
t += 0.012;
point.x = cx + r * Math.cos(t);
point.y = 110 + 90 * Math.sin(t * 0.7);
render();
if (t < Math.PI * 2) animId = requestAnimationFrame(step);
else animId = null;
}
step();
}
document.getElementById('animate').addEventListener('click', animate);
document.getElementById('reset').addEventListener('click', () => {
if (animId) { cancelAnimationFrame(animId); animId = null; }
point = { x: 270, y: 110 };
render();
});
render();
Comments
Loading comments...