Skip to content

Instantly share code, notes, and snippets.

@deostroll
Created July 26, 2026 15:29
Show Gist options
  • Select an option

  • Save deostroll/05ba5173418abe227afb9b56eee36d32 to your computer and use it in GitHub Desktop.

Select an option

Save deostroll/05ba5173418abe227afb9b56eee36d32 to your computer and use it in GitHub Desktop.
Angle bisector, interactively: compass construction, equidistance theorem, its converse, and an animated proof that a triangle's angle bisectors meet at the incenter. Self-contained HTML5/JS with GIF export.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Angle Bisector — Compass &amp; Straightedge Construction</title>
<style>
:root {
--bg: #0f172a;
--card-bg: #1e293b;
--accent-a: #38bdf8;
--accent-b: #f43f5e;
--bisector: #f59e0b;
--arc: #94a3b8;
--text: #f8fafc;
--text-muted: #94a3b8;
--btn: #334155;
--btn-hover: #475569;
}
* { box-sizing: border-box; margin: 0; padding: 0;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
body {
background-color: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
}
header { text-align: center; max-width: 820px; margin-bottom: 1.5rem; }
h1 { font-size: 1.7rem; margin-bottom: 0.4rem; }
p.subtitle { color: var(--text-muted); font-size: 0.95rem; line-height: 1.4; }
.container {
display: flex; flex-direction: column; gap: 1.5rem;
width: 100%; max-width: 1040px;
}
@media (min-width: 820px) {
.container { display: grid; grid-template-columns: 1fr 300px; }
}
.canvas-card, .side-card {
background-color: var(--card-bg);
border-radius: 12px;
padding: 1rem;
box-shadow: 0 10px 25px -5px rgba(0,0,0,0.3);
}
.canvas-card { display: flex; flex-direction: column; align-items: center; }
svg { width: 100%; height: auto; max-height: 540px; background: var(--bg);
border-radius: 8px; touch-action: none; }
.side-card { display: flex; flex-direction: column; gap: 1.1rem; padding: 1.4rem; }
.step-badge { color: var(--bisector); font-size: 0.8rem; font-weight: 700;
letter-spacing: 0.08em; text-transform: uppercase; }
.step-text { font-size: 0.98rem; line-height: 1.55; min-height: 6.5em; }
.btn-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
button {
background: var(--btn); color: var(--text); border: none;
border-radius: 8px; padding: 0.55rem 0.9rem; font-size: 0.9rem;
cursor: pointer; transition: background 0.15s;
}
button:hover:not(:disabled) { background: var(--btn-hover); }
button:disabled { opacity: 0.55; cursor: default; }
button.primary { background: #0369a1; }
button.primary:hover:not(:disabled) { background: #0284c7; }
button.gold { background: #92600a; }
button.gold:hover:not(:disabled) { background: #b45309; }
.control-group label { display: flex; justify-content: space-between;
font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.4rem; }
.control-group label span:last-child { color: var(--text); font-weight: 600; }
input[type=range] { width: 100%; accent-color: var(--bisector); }
footer { margin-top: 1.5rem; color: var(--text-muted); font-size: 0.85rem; }
footer a { color: var(--accent-a); text-decoration: none; margin: 0 0.3rem; }
footer a:hover { text-decoration: underline; }
</style>
</head>
<body>
<header>
<h1>Constructing an Angle Bisector</h1>
<p class="subtitle">The classic compass-and-straightedge construction: two arcs of equal
radius meet at a point that must be equally far from both rays.</p>
</header>
<div class="container">
<div class="canvas-card">
<svg id="scene" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg">
<!-- rays -->
<line id="rayOB" stroke="#f43f5e" stroke-width="3" stroke-linecap="round"/>
<line id="rayOA" stroke="#38bdf8" stroke-width="3" stroke-linecap="round"/>
<!-- construction arcs -->
<path id="arcO" fill="none" stroke="#94a3b8" stroke-width="2" stroke-dasharray="7 5" stroke-linecap="round"/>
<path id="arcQ" fill="none" stroke="#94a3b8" stroke-width="2" stroke-dasharray="7 5" stroke-linecap="round"/>
<path id="arcP" fill="none" stroke="#94a3b8" stroke-width="2" stroke-dasharray="7 5" stroke-linecap="round"/>
<!-- bisector ray -->
<line id="rayOR" stroke="#f59e0b" stroke-width="3.5" stroke-linecap="round"/>
<!-- equal-angle marks -->
<g id="marks">
<path id="markLow" fill="none" stroke="#f59e0b" stroke-width="2"/>
<path id="markHigh" fill="none" stroke="#f59e0b" stroke-width="2"/>
<line id="tickLow" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>
<line id="tickHigh" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>
</g>
<!-- points -->
<circle id="ptO" r="5" fill="#f8fafc"/>
<circle id="ptP" fill="#38bdf8"/>
<circle id="ptQ" fill="#f43f5e"/>
<circle id="ptR" fill="#f59e0b"/>
<!-- labels -->
<text id="lblO" font-size="18" fill="#f8fafc" font-weight="600">O</text>
<text id="lblA" font-size="18" fill="#38bdf8" font-weight="600">A</text>
<text id="lblB" font-size="18" fill="#f43f5e" font-weight="600">B</text>
<text id="lblP" font-size="17" fill="#38bdf8">P</text>
<text id="lblQ" font-size="17" fill="#f43f5e">Q</text>
<text id="lblR" font-size="17" fill="#f59e0b" font-weight="600">R</text>
<text id="caption" font-size="19" fill="#f59e0b" font-weight="600"
text-anchor="middle" x="460" y="478">OR bisects &#8736;AOB &#8212; &#8736;AOR = &#8736;ROB</text>
</svg>
</div>
<div class="side-card">
<div>
<div class="step-badge" id="stepBadge">Step 1 of 6</div>
<p class="step-text" id="stepText"></p>
</div>
<div class="btn-row">
<button id="playBtn" class="primary">&#10074;&#10074; Pause</button>
<button id="resetBtn">&#8634; Reset</button>
</div>
<div class="btn-row">
<button id="prevBtn">&#8592; Prev step</button>
<button id="nextBtn">Next step &#8594;</button>
</div>
<div class="control-group">
<label><span>Angle &#8736;AOB</span><span id="angleVal">70&#176;</span></label>
<input type="range" id="angleSlider" min="20" max="120" value="70" step="1">
</div>
<div class="btn-row">
<button id="gifBtn" class="gold">&#11015; Download GIF</button>
</div>
</div>
</div>
<footer>
Angle bisector series:
<a href="1-construction.html">1&#183;Construction</a>
<a href="2-equidistance.html">2&#183;Equidistance</a>
<a href="3-converse.html">3&#183;Converse</a>
<a href="4-incenter.html">4&#183;Incenter</a>
</footer>
<script src="gif-export.js"></script>
<script>
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
var svg = $('scene');
var O = { x: 150, y: 430 };
var R1 = 140; // compass radius for the first arc (centered at O)
var R2 = 135; // compass radius for the arcs at P and Q
var angleDeg = 70;
var DUR = 9000, HOLD = 1800;
// phase boundaries in normalized time t (phase i spans PH[i]..PH[i+1])
var PH = [0, 0.10, 0.30, 0.46, 0.62, 0.80, 1.00];
var STEPS = [
'Start with ∠AOB: vertex O, ray OA (blue) and ray OB (red). We want the ray that splits this angle exactly in half.',
'Place the compass point on O and swing an arc that crosses both rays. Mark the crossings P (on OA) and Q (on OB). By construction OP = OQ.',
'Move the compass to Q, keep the same opening, and draw an arc in the interior of the angle.',
'Move the compass to P with the same opening and draw a second arc. The two arcs intersect at a point R, with PR = QR.',
'Use the straightedge to draw the ray from O through R.',
'Triangles OPR and OQR have equal sides (OP = OQ, PR = QR, OR shared), so ∠AOR = ∠ROB: ray OR is the angle bisector.'
];
var T = 0, playing = true, recording = false, last = null;
/* ---------- geometry helpers (screen coords, y grows downward) ---------- */
function dir(aDeg) {
var a = aDeg * Math.PI / 180;
return { x: Math.cos(a), y: -Math.sin(a) };
}
function pt(c, r, aDeg) {
var d = dir(aDeg);
return { x: c.x + r * d.x, y: c.y + r * d.y };
}
function angleOf(from, to) { // math angle in degrees of vector from->to
return Math.atan2(-(to.y - from.y), to.x - from.x) * 180 / Math.PI;
}
function rayLen(aDeg) {
var d = dir(aDeg);
var L = 560;
if (d.x < 0) L = Math.min(L, (O.x - 24) / (-d.x));
if (d.x > 0) L = Math.min(L, (776 - O.x) / d.x);
if (d.y < 0) L = Math.min(L, (O.y - 30) / (-d.y));
return L;
}
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function ease(v) { return v * v * (3 - 2 * v); } // smoothstep
function arcPath(c, r, a0, a1) {
if (Math.abs(a1 - a0) < 0.05) return '';
var p0 = pt(c, r, a0), p1 = pt(c, r, a1);
var large = Math.abs(a1 - a0) > 180 ? 1 : 0;
var sweep = a1 > a0 ? 0 : 1;
return 'M ' + p0.x.toFixed(2) + ' ' + p0.y.toFixed(2) +
' A ' + r + ' ' + r + ' 0 ' + large + ' ' + sweep + ' ' +
p1.x.toFixed(2) + ' ' + p1.y.toFixed(2);
}
function setLine(el, a, b) {
el.setAttribute('x1', a.x); el.setAttribute('y1', a.y);
el.setAttribute('x2', b.x); el.setAttribute('y2', b.y);
}
function placeLabel(el, p, dx, dy) {
el.setAttribute('x', p.x + dx);
el.setAttribute('y', p.y + dy);
}
function geom() {
var th = angleDeg, half = th / 2;
var halfRad = half * Math.PI / 180;
var P = pt(O, R1, th);
var Q = pt(O, R1, 0);
// circles of radius R2 around P and Q meet on the bisector at distance s from O
var s = R1 * Math.cos(halfRad) +
Math.sqrt(R2 * R2 - Math.pow(R1 * Math.sin(halfRad), 2));
var R = pt(O, s, half);
return { th: th, half: half, P: P, Q: Q, R: R,
lenA: rayLen(th), lenB: rayLen(0), lenBis: rayLen(half) };
}
/* ---------- rendering ---------- */
function curPhase(t) {
for (var i = 5; i >= 0; i--) if (t >= PH[i]) return Math.min(i, 5);
return 0;
}
function render(t) {
var g = geom();
var lp = function (i) { return clamp01((t - PH[i]) / (PH[i + 1] - PH[i])); };
// phase 0: rays draw in (OB first half, OA second half)
var pB = ease(clamp01(lp(0) * 2));
var pA = ease(clamp01(lp(0) * 2 - 1));
var endB = pt(O, g.lenB * pB, 0);
var endA = pt(O, g.lenA * pA, g.th);
setLine($('rayOB'), O, endB);
setLine($('rayOA'), O, endA);
$('lblB').setAttribute('opacity', pB > 0.97 ? 1 : 0);
$('lblA').setAttribute('opacity', pA > 0.97 ? 1 : 0);
placeLabel($('lblB'), pt(O, g.lenB + 16, 0), -5, 6);
placeLabel($('lblA'), pt(O, g.lenA + 16, g.th), -5, 6);
placeLabel($('lblO'), O, -22, 18);
// phase 1: compass arc at O from just below OB to just past OA
var a0 = -10, a1 = g.th + 10;
var sweepEnd = a0 + (a1 - a0) * ease(lp(1));
$('arcO').setAttribute('d', t > PH[1] ? arcPath(O, R1, a0, sweepEnd) : '');
// P and Q pop when the arc crosses their rays
var qGrow = clamp01((sweepEnd - 0) / 10);
var pGrow = clamp01((sweepEnd - g.th) / 10);
if (t <= PH[1]) { qGrow = 0; pGrow = 0; }
$('ptQ').setAttribute('r', 5.5 * ease(qGrow));
$('ptP').setAttribute('r', 5.5 * ease(pGrow));
$('ptQ').setAttribute('cx', g.Q.x); $('ptQ').setAttribute('cy', g.Q.y);
$('ptP').setAttribute('cx', g.P.x); $('ptP').setAttribute('cy', g.P.y);
$('lblQ').setAttribute('opacity', qGrow >= 1 ? 1 : 0);
$('lblP').setAttribute('opacity', pGrow >= 1 ? 1 : 0);
placeLabel($('lblQ'), g.Q, -4, 26);
placeLabel($('lblP'), g.P, 10, -10);
// phases 2 and 3: arcs centered at Q, then P, sweeping toward R
var angQR = angleOf(g.Q, g.R);
var angPR = angleOf(g.P, g.R);
var arcHalf = 34;
var sQ = ease(lp(2)), sP = ease(lp(3));
$('arcQ').setAttribute('d', t > PH[2] ? arcPath(g.Q, R2, angQR - arcHalf, angQR - arcHalf + 2 * arcHalf * sQ) : '');
$('arcP').setAttribute('d', t > PH[3] ? arcPath(g.P, R2, angPR + arcHalf, angPR + arcHalf - 2 * arcHalf * sP) : '');
// R pops when the second arc crosses the first (at the midpoint of its sweep)
var rGrow = t > PH[3] ? clamp01((lp(3) - 0.5) / 0.15) : 0;
$('ptR').setAttribute('r', 6 * ease(rGrow));
$('ptR').setAttribute('cx', g.R.x); $('ptR').setAttribute('cy', g.R.y);
$('lblR').setAttribute('opacity', rGrow >= 1 ? 1 : 0);
placeLabel($('lblR'), g.R, 10, -8);
// phase 4: bisector ray draws in through R
var pBis = t > PH[4] ? ease(lp(4)) : 0;
setLine($('rayOR'), O, pt(O, g.lenBis * pBis, g.half));
// phase 5: equal-angle marks + caption; construction arcs fade back
var pm = t > PH[5] ? ease(lp(5)) : 0;
var mr = 46;
$('markLow').setAttribute('d', pm > 0 ? arcPath(O, mr, 0, g.half) : '');
$('markHigh').setAttribute('d', pm > 0 ? arcPath(O, mr, g.half, g.th) : '');
$('markLow').setAttribute('opacity', pm);
$('markHigh').setAttribute('opacity', pm);
var t1 = pt(O, mr, g.half / 2), t2 = pt(O, mr, g.half + g.half / 2);
var d1 = dir(g.half / 2), d2 = dir(g.half + g.half / 2);
setLine($('tickLow'), { x: t1.x - 7 * d1.x, y: t1.y - 7 * d1.y }, { x: t1.x + 7 * d1.x, y: t1.y + 7 * d1.y });
setLine($('tickHigh'), { x: t2.x - 7 * d2.x, y: t2.y - 7 * d2.y }, { x: t2.x + 7 * d2.x, y: t2.y + 7 * d2.y });
$('tickLow').setAttribute('opacity', pm);
$('tickHigh').setAttribute('opacity', pm);
$('caption').setAttribute('opacity', pm);
var arcFade = 1 - 0.65 * pm;
$('arcO').setAttribute('opacity', arcFade);
$('arcQ').setAttribute('opacity', arcFade);
$('arcP').setAttribute('opacity', arcFade);
// step panel
var ph = curPhase(Math.min(t, 0.999));
$('stepBadge').textContent = 'Step ' + (ph + 1) + ' of 6';
$('stepText').textContent = STEPS[ph];
}
/* ---------- playback ---------- */
function tick(ts) {
if (last === null) last = ts;
var dt = Math.min(ts - last, 100);
last = ts;
if (playing && !recording) {
T += dt;
if (T > DUR + HOLD) T = 0;
}
if (!recording) render(Math.min(T / DUR, 1));
requestAnimationFrame(tick);
}
function setPlaying(v) {
playing = v;
$('playBtn').innerHTML = v ? '&#10074;&#10074; Pause' : '&#9654; Play';
}
$('playBtn').addEventListener('click', function () { setPlaying(!playing); });
$('resetBtn').addEventListener('click', function () { T = 0; setPlaying(true); });
function stepJump(di) {
setPlaying(false);
var t = Math.min(T / DUR, 1);
var i = curPhase(Math.min(t, 0.999));
var atEnd = t >= PH[i + 1] - 0.005;
var target = di > 0 ? (atEnd ? i + 1 : i) : i - 1;
target = Math.max(0, Math.min(5, target));
T = (PH[target + 1] - 0.0001) * DUR;
}
$('prevBtn').addEventListener('click', function () { stepJump(-1); });
$('nextBtn').addEventListener('click', function () { stepJump(1); });
$('angleSlider').addEventListener('input', function () {
angleDeg = +this.value;
$('angleVal').innerHTML = angleDeg + '&#176;';
});
GifExport.wireGifButton($('gifBtn'), function () {
var wasPlaying = playing;
recording = true;
playing = false;
return {
svg: svg,
frames: 96,
delayMs: 100,
width: 640,
filename: 'angle-bisector-construction.gif',
renderFrame: function (i, n) {
render(Math.min(i / (n - 10), 1)); // ~10 hold frames at the end
},
onFinish: function () {
recording = false;
setPlaying(wasPlaying);
}
};
});
render(0);
requestAnimationFrame(tick);
})();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Angle Bisector — Equidistance Property</title>
<style>
:root {
--bg: #0f172a;
--card-bg: #1e293b;
--accent-a: #38bdf8;
--accent-b: #f43f5e;
--bisector: #f59e0b;
--perp-a: #22c55e;
--perp-b: #a855f7;
--text: #f8fafc;
--text-muted: #94a3b8;
--btn: #334155;
--btn-hover: #475569;
}
* { box-sizing: border-box; margin: 0; padding: 0;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
body {
background-color: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
}
header { text-align: center; max-width: 820px; margin-bottom: 1.5rem; }
h1 { font-size: 1.7rem; margin-bottom: 0.4rem; }
p.subtitle { color: var(--text-muted); font-size: 0.95rem; line-height: 1.4; }
.container {
display: flex; flex-direction: column; gap: 1.5rem;
width: 100%; max-width: 1040px;
}
@media (min-width: 820px) {
.container { display: grid; grid-template-columns: 1fr 300px; }
}
.canvas-card, .side-card {
background-color: var(--card-bg);
border-radius: 12px;
padding: 1rem;
box-shadow: 0 10px 25px -5px rgba(0,0,0,0.3);
}
.canvas-card { display: flex; flex-direction: column; align-items: center; }
svg { width: 100%; height: auto; max-height: 540px; background: var(--bg);
border-radius: 8px; touch-action: none; }
.side-card { display: flex; flex-direction: column; gap: 1.1rem; padding: 1.4rem; }
.readout {
display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
gap: 0.4rem; background: #0f172a; border-radius: 10px; padding: 0.9rem 0.6rem;
text-align: center;
}
.readout .val { font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.readout .val.a { color: var(--perp-a); }
.readout .val.b { color: var(--perp-b); }
.readout .eq { font-size: 1.6rem; color: var(--bisector); font-weight: 700; }
.readout .cap { font-size: 0.75rem; color: var(--text-muted); }
.hint { font-size: 0.9rem; color: var(--text-muted); line-height: 1.5; }
.hint b { color: var(--bisector); }
.btn-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
button {
background: var(--btn); color: var(--text); border: none;
border-radius: 8px; padding: 0.55rem 0.9rem; font-size: 0.9rem;
cursor: pointer; transition: background 0.15s;
}
button:hover:not(:disabled) { background: var(--btn-hover); }
button:disabled { opacity: 0.55; cursor: default; }
button.primary { background: #0369a1; }
button.primary:hover:not(:disabled) { background: #0284c7; }
button.gold { background: #92600a; }
button.gold:hover:not(:disabled) { background: #b45309; }
.control-group label { display: flex; justify-content: space-between;
font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.4rem; }
.control-group label span:last-child { color: var(--text); font-weight: 600; }
input[type=range] { width: 100%; accent-color: var(--bisector); }
footer { margin-top: 1.5rem; color: var(--text-muted); font-size: 0.85rem; }
footer a { color: var(--accent-a); text-decoration: none; margin: 0 0.3rem; }
footer a:hover { text-decoration: underline; }
</style>
</head>
<body>
<header>
<h1>A Point on the Bisector Is Equidistant from Both Sides</h1>
<p class="subtitle">Drag the gold point P along the bisector &#8212; the two perpendicular
distances stay exactly equal, no matter where P sits or how wide the angle is.</p>
</header>
<div class="container">
<div class="canvas-card">
<svg id="scene" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg">
<!-- rays -->
<line id="rayOA" stroke="#38bdf8" stroke-width="3" stroke-linecap="round"/>
<line id="rayOB" stroke="#f43f5e" stroke-width="3" stroke-linecap="round"/>
<!-- bisector -->
<line id="bisector" stroke="#f59e0b" stroke-width="2.5" stroke-dasharray="9 7" stroke-linecap="round"/>
<!-- perpendiculars -->
<line id="perpA" stroke="#22c55e" stroke-width="2.5" stroke-linecap="round"/>
<line id="perpB" stroke="#a855f7" stroke-width="2.5" stroke-linecap="round"/>
<polyline id="sqA" fill="none" stroke="#22c55e" stroke-width="1.6"/>
<polyline id="sqB" fill="none" stroke="#a855f7" stroke-width="1.6"/>
<!-- points -->
<circle id="ptO" r="5" fill="#f8fafc"/>
<circle id="footA" r="4" fill="#22c55e"/>
<circle id="footB" r="4" fill="#a855f7"/>
<circle id="haloP" r="17" fill="#f59e0b" opacity="0.22"/>
<circle id="ptP" r="9" fill="#f59e0b" stroke="#0f172a" stroke-width="2" cursor="grab"/>
<!-- labels -->
<text id="lblO" font-size="18" fill="#f8fafc" font-weight="600">O</text>
<text id="lblA" font-size="18" fill="#38bdf8" font-weight="600">A</text>
<text id="lblB" font-size="18" fill="#f43f5e" font-weight="600">B</text>
<text id="lblP" font-size="18" fill="#f59e0b" font-weight="700">P</text>
<text id="lblD1" font-size="15" fill="#22c55e" font-weight="600" text-anchor="middle"></text>
<text id="lblD2" font-size="15" fill="#a855f7" font-weight="600" text-anchor="middle"></text>
<text id="lblBis" font-size="14" fill="#f59e0b" opacity="0.85">bisector</text>
</svg>
</div>
<div class="side-card">
<div class="readout">
<div><div class="val a" id="d1Val">0.0</div><div class="cap">d&#8321; = dist to OA</div></div>
<div class="eq">=</div>
<div><div class="val b" id="d2Val">0.0</div><div class="cap">d&#8322; = dist to OB</div></div>
</div>
<p class="hint">P lies on the bisector, so the right triangles OPF&#8321; and OPF&#8322;
share the hypotenuse OP and have equal angles at O &#8212; they are congruent,
hence <b>d&#8321; = d&#8322; always</b>.</p>
<div class="btn-row">
<button id="playBtn" class="primary">&#10074;&#10074; Pause</button>
<button id="resetBtn">&#8634; Reset</button>
</div>
<div class="control-group">
<label><span>Angle &#8736;AOB</span><span id="angleVal">80&#176;</span></label>
<input type="range" id="angleSlider" min="30" max="140" value="80" step="1">
</div>
<div class="btn-row">
<button id="gifBtn" class="gold">&#11015; Download GIF</button>
</div>
</div>
</div>
<footer>
Angle bisector series:
<a href="1-construction.html">1&#183;Construction</a>
<a href="2-equidistance.html">2&#183;Equidistance</a>
<a href="3-converse.html">3&#183;Converse</a>
<a href="4-incenter.html">4&#183;Incenter</a>
</footer>
<script src="gif-export.js"></script>
<script>
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
var svg = $('scene');
var O = { x: 100, y: 250 };
var theta = 80; // full angle AOB, bisector horizontal
var SMIN = 70;
var PERIOD = 6000; // ms per oscillation
var s = 260; // distance of P from O along the bisector
var phase = 0; // oscillation phase in [0,1)
var playing = true, dragging = false, recording = false;
var wasPlayingBeforeDrag = false;
var last = null;
function phi() { return theta / 2 * Math.PI / 180; }
function rayLen() {
var f = phi();
return Math.min((780 - O.x) / Math.cos(f), (O.y - 30) / Math.sin(f), 640);
}
function smax() {
return Math.min(500, rayLen() / Math.cos(phi()) * 0.95);
}
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function setLine(el, a, b) {
el.setAttribute('x1', a.x); el.setAttribute('y1', a.y);
el.setAttribute('x2', b.x); el.setAttribute('y2', b.y);
}
function place(el, x, y) { el.setAttribute('x', x); el.setAttribute('y', y); }
// right-angle square at foot F: sides along ray dir u and perpendicular v (toward P)
function squarePts(F, u, v, size) {
var a = { x: F.x - u.x * size, y: F.y - u.y * size };
var b = { x: a.x + v.x * size, y: a.y + v.y * size };
var c = { x: F.x + v.x * size, y: F.y + v.y * size };
return a.x.toFixed(1) + ',' + a.y.toFixed(1) + ' ' +
b.x.toFixed(1) + ',' + b.y.toFixed(1) + ' ' +
c.x.toFixed(1) + ',' + c.y.toFixed(1);
}
function render() {
var f = phi();
var L = rayLen();
var uA = { x: Math.cos(f), y: -Math.sin(f) }; // upper ray direction
var uB = { x: Math.cos(f), y: Math.sin(f) }; // lower ray direction
var endA = { x: O.x + uA.x * L, y: O.y + uA.y * L };
var endB = { x: O.x + uB.x * L, y: O.y + uB.y * L };
setLine($('rayOA'), O, endA);
setLine($('rayOB'), O, endB);
setLine($('bisector'), O, { x: 770, y: O.y });
s = clamp(s, SMIN, smax());
var P = { x: O.x + s, y: O.y };
var along = s * Math.cos(f); // distance of each foot along its ray
var FA = { x: O.x + uA.x * along, y: O.y + uA.y * along };
var FB = { x: O.x + uB.x * along, y: O.y + uB.y * along };
var d = s * Math.sin(f); // the (equal) perpendicular distance
setLine($('perpA'), P, FA);
setLine($('perpB'), P, FB);
// perpendicular direction from foot toward P
var vA = { x: (P.x - FA.x) / d, y: (P.y - FA.y) / d };
var vB = { x: (P.x - FB.x) / d, y: (P.y - FB.y) / d };
$('sqA').setAttribute('points', squarePts(FA, uA, vA, 10));
$('sqB').setAttribute('points', squarePts(FB, uB, vB, 10));
$('ptO').setAttribute('cx', O.x); $('ptO').setAttribute('cy', O.y);
$('footA').setAttribute('cx', FA.x); $('footA').setAttribute('cy', FA.y);
$('footB').setAttribute('cx', FB.x); $('footB').setAttribute('cy', FB.y);
$('ptP').setAttribute('cx', P.x); $('ptP').setAttribute('cy', P.y);
$('haloP').setAttribute('cx', P.x); $('haloP').setAttribute('cy', P.y);
place($('lblO'), O.x - 24, O.y + 6);
place($('lblA'), endA.x - 2, endA.y - 10);
place($('lblB'), endB.x - 2, endB.y + 22);
place($('lblP'), P.x - 6, P.y + 30);
place($('lblBis'), 690, O.y - 10);
place($('lblD1'), (P.x + FA.x) / 2 + 26, (P.y + FA.y) / 2);
place($('lblD2'), (P.x + FB.x) / 2 + 26, (P.y + FB.y) / 2 + 8);
var units = (d / 10).toFixed(1);
$('lblD1').textContent = 'd₁ = ' + units;
$('lblD2').textContent = 'd₂ = ' + units;
$('d1Val').textContent = units;
$('d2Val').textContent = units;
}
function renderAtTime(u) { // u in [0,1) — one oscillation loop
var mid = (SMIN + smax()) / 2;
var amp = (smax() - SMIN) / 2 * 0.9;
s = mid + amp * Math.sin(2 * Math.PI * u);
render();
}
/* ---------- playback ---------- */
function tick(ts) {
if (last === null) last = ts;
var dt = Math.min(ts - last, 100);
last = ts;
if (!recording) {
if (playing && !dragging) {
phase = (phase + dt / PERIOD) % 1;
renderAtTime(phase);
} else {
render();
}
}
requestAnimationFrame(tick);
}
function setPlaying(v) {
playing = v;
$('playBtn').innerHTML = v ? '&#10074;&#10074; Pause' : '&#9654; Play';
}
$('playBtn').addEventListener('click', function () { setPlaying(!playing); });
$('resetBtn').addEventListener('click', function () {
phase = 0; s = 260; theta = 80;
$('angleSlider').value = 80;
$('angleVal').innerHTML = '80&#176;';
setPlaying(true);
});
$('angleSlider').addEventListener('input', function () {
theta = +this.value;
$('angleVal').innerHTML = theta + '&#176;';
});
/* ---------- dragging ---------- */
function svgPoint(evt) {
var p = svg.createSVGPoint();
p.x = evt.clientX; p.y = evt.clientY;
return p.matrixTransform(svg.getScreenCTM().inverse());
}
$('ptP').addEventListener('pointerdown', function (evt) {
dragging = true;
wasPlayingBeforeDrag = playing;
setPlaying(false);
this.setPointerCapture(evt.pointerId);
this.setAttribute('cursor', 'grabbing');
evt.preventDefault();
});
$('ptP').addEventListener('pointermove', function (evt) {
if (!dragging) return;
var p = svgPoint(evt);
s = clamp(p.x - O.x, SMIN, smax());
});
$('ptP').addEventListener('pointerup', function () {
dragging = false;
this.setAttribute('cursor', 'grab');
if (wasPlayingBeforeDrag) {
// resume the oscillation from P's current position
var mid = (SMIN + smax()) / 2;
var amp = (smax() - SMIN) / 2 * 0.9;
var v = clamp((s - mid) / amp, -1, 1);
phase = Math.asin(v) / (2 * Math.PI);
if (phase < 0) phase += 1;
setPlaying(true);
}
});
/* ---------- GIF export ---------- */
GifExport.wireGifButton($('gifBtn'), function () {
var wasPlaying = playing;
recording = true;
playing = false;
return {
svg: svg,
frames: 60,
delayMs: 100,
width: 640,
filename: 'angle-bisector-equidistance.gif',
renderFrame: function (i, n) { renderAtTime(i / n); },
onFinish: function () {
recording = false;
setPlaying(wasPlaying);
}
};
});
render();
requestAnimationFrame(tick);
})();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Angle Bisector — The Converse</title>
<style>
:root {
--bg: #0f172a;
--card-bg: #1e293b;
--accent-a: #38bdf8;
--accent-b: #f43f5e;
--bisector: #f59e0b;
--perp-a: #22c55e;
--perp-b: #a855f7;
--text: #f8fafc;
--text-muted: #94a3b8;
--btn: #334155;
--btn-hover: #475569;
}
* { box-sizing: border-box; margin: 0; padding: 0;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
body {
background-color: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
}
header { text-align: center; max-width: 840px; margin-bottom: 1.5rem; }
h1 { font-size: 1.7rem; margin-bottom: 0.4rem; }
p.subtitle { color: var(--text-muted); font-size: 0.95rem; line-height: 1.4; }
.container {
display: flex; flex-direction: column; gap: 1.5rem;
width: 100%; max-width: 1040px;
}
@media (min-width: 820px) {
.container { display: grid; grid-template-columns: 1fr 300px; }
}
.canvas-card, .side-card {
background-color: var(--card-bg);
border-radius: 12px;
padding: 1rem;
box-shadow: 0 10px 25px -5px rgba(0,0,0,0.3);
}
.canvas-card { display: flex; flex-direction: column; align-items: center; }
svg { width: 100%; height: auto; max-height: 540px; background: var(--bg);
border-radius: 8px; touch-action: none; }
.side-card { display: flex; flex-direction: column; gap: 1.1rem; padding: 1.4rem; }
.meter { background: #0f172a; border-radius: 10px; padding: 0.9rem; }
.meter .row { display: grid; grid-template-columns: 2.2em 1fr 3.2em; align-items: center;
gap: 0.5rem; margin-bottom: 0.45rem; font-size: 0.9rem; }
.meter .row:last-child { margin-bottom: 0; }
.meter .bar { height: 10px; border-radius: 5px; background: #1e293b; overflow: hidden; }
.meter .bar > div { height: 100%; border-radius: 5px; width: 0; }
.meter .bar.a > div { background: var(--perp-a); }
.meter .bar.b > div { background: var(--perp-b); }
.meter .num { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
.meter .num.a { color: var(--perp-a); }
.meter .num.b { color: var(--perp-b); }
.verdict {
min-height: 3.6em; font-size: 0.92rem; line-height: 1.45;
border-left: 3px solid var(--btn); padding-left: 0.7rem; color: var(--text-muted);
}
.verdict.on { border-color: var(--bisector); color: var(--text); }
.verdict b { color: var(--bisector); }
.btn-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
button {
background: var(--btn); color: var(--text); border: none;
border-radius: 8px; padding: 0.55rem 0.9rem; font-size: 0.9rem;
cursor: pointer; transition: background 0.15s;
}
button:hover:not(:disabled) { background: var(--btn-hover); }
button:disabled { opacity: 0.55; cursor: default; }
button.primary { background: #0369a1; }
button.primary:hover:not(:disabled) { background: #0284c7; }
button.gold { background: #92600a; }
button.gold:hover:not(:disabled) { background: #b45309; }
.control-group label { display: flex; justify-content: space-between;
font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.4rem; }
.control-group label span:last-child { color: var(--text); font-weight: 600; }
input[type=range] { width: 100%; accent-color: var(--bisector); }
footer { margin-top: 1.5rem; color: var(--text-muted); font-size: 0.85rem; }
footer a { color: var(--accent-a); text-decoration: none; margin: 0 0.3rem; }
footer a:hover { text-decoration: underline; }
</style>
</head>
<body>
<header>
<h1>Converse: Equidistant Points Lie on the Bisector</h1>
<p class="subtitle">Drag P anywhere inside the angle and watch d&#8321;, d&#8322; and the two
angles &#945;&#8321; = &#8736;AOP, &#945;&#8322; = &#8736;POB &#8212; the distances are equal
exactly when the angles are equal. Run the trace: every equidistant point lands on one
straight line through O, the angle bisector.</p>
</header>
<div class="container">
<div class="canvas-card">
<svg id="scene" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg">
<!-- rays -->
<line id="rayOA" stroke="#38bdf8" stroke-width="3" stroke-linecap="round"/>
<line id="rayOB" stroke="#f43f5e" stroke-width="3" stroke-linecap="round"/>
<!-- trace layer -->
<g id="traceLayer">
<line id="scanLine" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4 5" opacity="0"/>
<g id="dots"></g>
<line id="bisLine" stroke="#f59e0b" stroke-width="2.5" stroke-dasharray="9 7"
stroke-linecap="round" opacity="0"/>
<text id="bisLbl" font-size="15" fill="#f59e0b" font-weight="600" opacity="0">the bisector</text>
</g>
<!-- circular path for orbit mode -->
<circle id="orbitPath" fill="none" stroke="#64748b" stroke-width="1.5"
stroke-dasharray="4 6" opacity="0"/>
<circle id="orbitCross1" r="0" fill="#f59e0b" opacity="0.9"/>
<circle id="orbitCross2" r="0" fill="#f59e0b" opacity="0.9"/>
<!-- segment OP and the two angles at O -->
<line id="segOP" stroke="#64748b" stroke-width="1.8" stroke-dasharray="5 5"/>
<path id="angArcA" fill="none" stroke="#22c55e" stroke-width="2"/>
<path id="angArcB" fill="none" stroke="#a855f7" stroke-width="2"/>
<text id="angLblA" font-size="14" fill="#22c55e" font-weight="600" text-anchor="middle"></text>
<text id="angLblB" font-size="14" fill="#a855f7" font-weight="600" text-anchor="middle"></text>
<!-- perpendiculars -->
<line id="perpA" stroke="#22c55e" stroke-width="2.5" stroke-linecap="round"/>
<line id="perpB" stroke="#a855f7" stroke-width="2.5" stroke-linecap="round"/>
<polyline id="sqA" fill="none" stroke="#22c55e" stroke-width="1.6"/>
<polyline id="sqB" fill="none" stroke="#a855f7" stroke-width="1.6"/>
<!-- points -->
<circle id="ptO" r="5" fill="#f8fafc"/>
<circle id="footA" r="4" fill="#22c55e"/>
<circle id="footB" r="4" fill="#a855f7"/>
<circle id="haloP" r="18" fill="#f59e0b" opacity="0"/>
<circle id="ptP" r="9" fill="#e2e8f0" stroke="#0f172a" stroke-width="2" cursor="grab"/>
<!-- labels -->
<text id="lblO" font-size="18" fill="#f8fafc" font-weight="600">O</text>
<text id="lblA" font-size="18" fill="#38bdf8" font-weight="600">A</text>
<text id="lblB" font-size="18" fill="#f43f5e" font-weight="600">B</text>
<text id="lblP" font-size="18" fill="#f8fafc" font-weight="700">P</text>
<text id="lblD1" font-size="15" fill="#22c55e" font-weight="600" text-anchor="middle"></text>
<text id="lblD2" font-size="15" fill="#a855f7" font-weight="600" text-anchor="middle"></text>
</svg>
</div>
<div class="side-card">
<div class="meter">
<div class="row">
<span style="color:var(--perp-a)">d&#8321;</span>
<div class="bar a"><div id="bar1"></div></div>
<span class="num a" id="d1Val">0.0</span>
</div>
<div class="row">
<span style="color:var(--perp-b)">d&#8322;</span>
<div class="bar b"><div id="bar2"></div></div>
<span class="num b" id="d2Val">0.0</span>
</div>
</div>
<p class="verdict" id="verdict"></p>
<div class="btn-row">
<button id="traceBtn" class="primary">&#9654; Trace equal points</button>
<button id="orbitBtn">&#9711; Move P on a circle</button>
<button id="resetBtn">&#8634; Reset</button>
</div>
<div class="control-group">
<label><span>Angle &#8736;AOB</span><span id="angleVal">80&#176;</span></label>
<input type="range" id="angleSlider" min="30" max="140" value="80" step="1">
</div>
<div class="btn-row">
<button id="gifBtn" class="gold">&#11015; Download GIF</button>
</div>
</div>
</div>
<footer>
Angle bisector series:
<a href="1-construction.html">1&#183;Construction</a>
<a href="2-equidistance.html">2&#183;Equidistance</a>
<a href="3-converse.html">3&#183;Converse</a>
<a href="4-incenter.html">4&#183;Incenter</a>
</footer>
<script src="gif-export.js"></script>
<script>
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
var svg = $('scene');
var NS = 'http://www.w3.org/2000/svg';
var O = { x: 100, y: 250 };
var theta = 80; // full angle, bisector horizontal at y = O.y
var P = { x: 340, y: 195 };
var EPS = 1.6; // "equal" tolerance in px
var SCAN_XS = [220, 320, 420, 520, 620];
var TRACE_DUR = 9000;
var mode = 'free'; // 'free' | 'trace' | 'orbit'
var traceT = 0; // trace timeline progress 0..1
var traceDone = false;
var orbitT = 0; // orbit phase 0..1
var ORBIT_PERIOD = 8000; // ms per revolution
var dragging = false, recording = false;
var last = null;
function phi() { return theta / 2 * Math.PI / 180; }
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function rayLen() {
var f = phi();
return Math.min((780 - O.x) / Math.cos(f), (O.y - 30) / Math.sin(f), 640);
}
function setLine(el, a, b) {
el.setAttribute('x1', a.x); el.setAttribute('y1', a.y);
el.setAttribute('x2', b.x); el.setAttribute('y2', b.y);
}
function place(el, x, y) { el.setAttribute('x', x); el.setAttribute('y', y); }
// keep P strictly inside the angle (polar clamp around O)
function clampInside(p) {
var f = phi();
var dx = p.x - O.x, dy = p.y - O.y;
var r = Math.sqrt(dx * dx + dy * dy);
var a = Math.atan2(-dy, dx); // math angle, bisector = 0
var margin = 6 * Math.PI / 180;
a = clamp(a, -f + margin, f - margin);
r = clamp(r, 60, 640);
var q = { x: O.x + r * Math.cos(a), y: O.y - r * Math.sin(a) };
q.x = clamp(q.x, O.x + 20, 762);
q.y = clamp(q.y, 36, 464);
return q;
}
function arcPath(c, r, a0, a1) { // math angles in degrees, y flipped on screen
if (Math.abs(a1 - a0) < 0.05) return '';
var p0 = { x: c.x + r * Math.cos(a0 * Math.PI / 180), y: c.y - r * Math.sin(a0 * Math.PI / 180) };
var p1 = { x: c.x + r * Math.cos(a1 * Math.PI / 180), y: c.y - r * Math.sin(a1 * Math.PI / 180) };
var large = Math.abs(a1 - a0) > 180 ? 1 : 0;
var sweep = a1 > a0 ? 0 : 1;
return 'M ' + p0.x.toFixed(2) + ' ' + p0.y.toFixed(2) +
' A ' + r + ' ' + r + ' 0 ' + large + ' ' + sweep + ' ' +
p1.x.toFixed(2) + ' ' + p1.y.toFixed(2);
}
function squarePts(F, u, v, size) {
var a = { x: F.x - u.x * size, y: F.y - u.y * size };
var b = { x: a.x + v.x * size, y: a.y + v.y * size };
var c = { x: F.x + v.x * size, y: F.y + v.y * size };
return a.x.toFixed(1) + ',' + a.y.toFixed(1) + ' ' +
b.x.toFixed(1) + ',' + b.y.toFixed(1) + ' ' +
c.x.toFixed(1) + ',' + c.y.toFixed(1);
}
// scan segment extents at a given x (just inside the two rays)
function scanYs(x) {
var f = phi();
var spread = (x - O.x) * Math.tan(f);
var yTop = Math.max(40, O.y - spread + 10);
var yBot = Math.min(460, O.y + spread - 10);
return { top: yTop, bot: yBot };
}
/* ---------- orbit mode ---------- */
// circle centered on the bisector, sized to stay inside the angle
function orbitGeom() {
var cx = O.x + 260;
var r = Math.min(0.8 * 260 * Math.sin(phi()), 140);
return { c: { x: cx, y: O.y }, r: r };
}
// time-warp: dwell briefly at u = 0 and u = 0.5 (the bisector crossings),
// easing in and out of each pause
function orbitWarp(u) {
var halfIdx = u < 0.5 ? 0 : 1;
var s = (u - halfIdx * 0.5) * 2; // progress within this half-revolution
var h = 0.14; // hold fraction at each end of the half
var m;
if (s < h) m = 0;
else if (s > 1 - h) m = 1;
else {
var x = (s - h) / (1 - 2 * h);
m = x * x * (3 - 2 * x); // smoothstep between the pauses
}
return (halfIdx + m) * 0.5;
}
function orbitPos(u) { // u in [0,1) — one revolution, starts on the bisector
var g = orbitGeom();
var a = 2 * Math.PI * orbitWarp(u % 1);
return { x: g.c.x + g.r * Math.cos(a), y: g.c.y + g.r * Math.sin(a) };
}
function showOrbitPath(v) {
var g = orbitGeom();
var el = $('orbitPath');
el.setAttribute('cx', g.c.x);
el.setAttribute('cy', g.c.y);
el.setAttribute('r', g.r);
el.setAttribute('opacity', v ? 0.7 : 0);
// gold markers where the circle crosses the bisector — the two
// positions on the orbit where P is equidistant from both rays
var c1 = $('orbitCross1'), c2 = $('orbitCross2');
c1.setAttribute('cx', g.c.x + g.r); c1.setAttribute('cy', g.c.y);
c2.setAttribute('cx', g.c.x - g.r); c2.setAttribute('cy', g.c.y);
c1.setAttribute('r', v ? 4.5 : 0);
c2.setAttribute('r', v ? 4.5 : 0);
}
function stopOrbit() {
if (mode === 'orbit') mode = 'free';
showOrbitPath(false);
$('orbitBtn').innerHTML = '&#9711; Move P on a circle';
}
/* ---------- trace dots ---------- */
var dotEls = [];
function buildDots() {
var g = $('dots');
while (g.firstChild) g.removeChild(g.firstChild);
dotEls = SCAN_XS.map(function (x) {
var c = document.createElementNS(NS, 'circle');
c.setAttribute('cx', x);
c.setAttribute('cy', O.y);
c.setAttribute('r', 0);
c.setAttribute('fill', '#f59e0b');
g.appendChild(c);
return c;
});
}
/* ---------- rendering ---------- */
function renderScene() {
var f = phi();
var L = rayLen();
var uA = { x: Math.cos(f), y: -Math.sin(f) };
var uB = { x: Math.cos(f), y: Math.sin(f) };
var endA = { x: O.x + uA.x * L, y: O.y + uA.y * L };
var endB = { x: O.x + uB.x * L, y: O.y + uB.y * L };
setLine($('rayOA'), O, endA);
setLine($('rayOB'), O, endB);
$('ptO').setAttribute('cx', O.x); $('ptO').setAttribute('cy', O.y);
place($('lblO'), O.x - 24, O.y + 6);
place($('lblA'), endA.x - 2, endA.y - 10);
place($('lblB'), endB.x - 2, endB.y + 22);
// perpendicular feet: projection of P onto each ray
var dx = P.x - O.x, dy = P.y - O.y;
var tA = dx * uA.x + dy * uA.y;
var tB = dx * uB.x + dy * uB.y;
var FA = { x: O.x + uA.x * tA, y: O.y + uA.y * tA };
var FB = { x: O.x + uB.x * tB, y: O.y + uB.y * tB };
var d1 = Math.hypot(P.x - FA.x, P.y - FA.y);
var d2 = Math.hypot(P.x - FB.x, P.y - FB.y);
setLine($('perpA'), P, FA);
setLine($('perpB'), P, FB);
var vA = d1 > 0.01 ? { x: (P.x - FA.x) / d1, y: (P.y - FA.y) / d1 } : { x: 0, y: 1 };
var vB = d2 > 0.01 ? { x: (P.x - FB.x) / d2, y: (P.y - FB.y) / d2 } : { x: 0, y: -1 };
$('sqA').setAttribute('points', squarePts(FA, uA, vA, 9));
$('sqB').setAttribute('points', squarePts(FB, uB, vB, 9));
$('footA').setAttribute('cx', FA.x); $('footA').setAttribute('cy', FA.y);
$('footB').setAttribute('cx', FB.x); $('footB').setAttribute('cy', FB.y);
var equal = Math.abs(d1 - d2) < EPS;
$('ptP').setAttribute('cx', P.x); $('ptP').setAttribute('cy', P.y);
$('ptP').setAttribute('fill', equal ? '#f59e0b' : '#e2e8f0');
$('haloP').setAttribute('cx', P.x); $('haloP').setAttribute('cy', P.y);
$('haloP').setAttribute('opacity', equal ? 0.25 : 0);
place($('lblP'), P.x + 14, P.y + 5);
$('lblP').setAttribute('fill', equal ? '#f59e0b' : '#f8fafc');
place($('lblD1'), (P.x + FA.x) / 2 + 28, (P.y + FA.y) / 2);
place($('lblD2'), (P.x + FB.x) / 2 + 28, (P.y + FB.y) / 2 + 8);
$('lblD1').textContent = 'd₁ = ' + (d1 / 10).toFixed(1);
$('lblD2').textContent = 'd₂ = ' + (d2 / 10).toFixed(1);
// segment OP and the angles it makes with each ray
setLine($('segOP'), O, P);
var fdeg = theta / 2;
var aP = Math.atan2(-(P.y - O.y), P.x - O.x) * 180 / Math.PI; // OP direction, bisector = 0
var a1 = fdeg - aP; // α₁ = ∠AOP
var a2 = aP + fdeg; // α₂ = ∠POB
$('angArcA').setAttribute('d', arcPath(O, 46, aP, fdeg));
$('angArcB').setAttribute('d', arcPath(O, 38, -fdeg, aP));
$('angArcA').setAttribute('stroke', equal ? '#f59e0b' : '#22c55e');
$('angArcB').setAttribute('stroke', equal ? '#f59e0b' : '#a855f7');
$('angLblA').setAttribute('fill', equal ? '#f59e0b' : '#22c55e');
$('angLblB').setAttribute('fill', equal ? '#f59e0b' : '#a855f7');
// labels sit on the angle's mid-direction, pushed out when the angle is narrow
var m1 = (aP + fdeg) / 2 * Math.PI / 180;
var m2 = (aP - fdeg) / 2 * Math.PI / 180;
var r1 = a1 < 16 ? 118 : 86;
var r2 = a2 < 16 ? 108 : 74;
place($('angLblA'), O.x + r1 * Math.cos(m1), O.y - r1 * Math.sin(m1) + 5);
place($('angLblB'), O.x + r2 * Math.cos(m2), O.y - r2 * Math.sin(m2) + 5);
$('angLblA').textContent = 'α₁ = ' + a1.toFixed(0) + '°';
$('angLblB').textContent = 'α₂ = ' + a2.toFixed(0) + '°';
// side panel
$('d1Val').textContent = (d1 / 10).toFixed(1);
$('d2Val').textContent = (d2 / 10).toFixed(1);
var maxBar = 45; // px distance mapped to full bar
$('bar1').style.width = clamp(d1 / 10 / maxBar * 100, 2, 100) + '%';
$('bar2').style.width = clamp(d2 / 10 / maxBar * 100, 2, 100) + '%';
var v = $('verdict');
if (equal) {
v.className = 'verdict on';
v.innerHTML = 'd&#8321; = d&#8322; and &#945;&#8321; = &#945;&#8322; &#8212; P is ' +
'equidistant from both sides, so <b>P lies on the bisector</b>, ' +
'and OP splits &#8736;AOB into two equal angles.';
} else {
v.className = 'verdict';
v.innerHTML = 'd&#8321; &#8800; d&#8322; (&#945;&#8321; = ' + a1.toFixed(0) +
'&#176;, &#945;&#8322; = ' + a2.toFixed(0) + '&#176;) &#8212; P is closer to ray ' +
(d1 < d2 ? 'OA' : 'OB') + ', so it is <em>not</em> on the bisector.';
}
}
function renderTrace(t) {
// scans occupy [0, 0.82] of the timeline, then the bisector line fades in
var n = SCAN_XS.length;
var slot = 0.82 / n;
var scan = Math.min(n - 1, Math.floor(t / slot));
var lp = clamp((t - scan * slot) / (slot * 0.9), 0, 1); // small gap between scans
if (t < 0.82) {
var x = SCAN_XS[scan];
var ys = scanYs(x);
$('scanLine').setAttribute('opacity', 0.7);
setLine($('scanLine'), { x: x, y: ys.top }, { x: x, y: ys.bot });
P = { x: x, y: ys.top + (ys.bot - ys.top) * lp };
} else {
$('scanLine').setAttribute('opacity', 0);
}
// a dot appears when its scan passes the midpoint (the equidistant point)
for (var i = 0; i < dotEls.length; i++) {
var passed = (i < scan) || (i === scan && lp >= 0.5) || t >= 0.82;
var justNow = (i === scan && lp >= 0.5 && lp < 0.62);
dotEls[i].setAttribute('r', passed ? (justNow ? 9 : 5.5) : 0);
}
// reveal the bisector through the dots
var reveal = clamp((t - 0.85) / 0.13, 0, 1);
$('bisLine').setAttribute('opacity', reveal);
setLine($('bisLine'), O, { x: 770, y: O.y });
place($('bisLbl'), 640, O.y - 12);
$('bisLbl').setAttribute('opacity', reveal);
renderScene();
}
/* ---------- playback ---------- */
function tick(ts) {
if (last === null) last = ts;
var dt = Math.min(ts - last, 100);
last = ts;
if (!recording) {
if (mode === 'trace' && !traceDone) {
traceT = Math.min(traceT + dt / TRACE_DUR, 1);
if (traceT >= 1) {
traceDone = true;
$('traceBtn').innerHTML = '&#9654; Trace again';
}
renderTrace(traceT);
} else if (mode === 'orbit') {
orbitT = (orbitT + dt / ORBIT_PERIOD) % 1;
P = orbitPos(orbitT);
showOrbitPath(true);
renderScene();
} else {
renderScene();
}
}
requestAnimationFrame(tick);
}
function clearTrace() {
traceT = 0;
traceDone = false;
mode = 'free';
$('scanLine').setAttribute('opacity', 0);
$('bisLine').setAttribute('opacity', 0);
$('bisLbl').setAttribute('opacity', 0);
buildDots();
$('traceBtn').innerHTML = '&#9654; Trace equal points';
}
$('traceBtn').addEventListener('click', function () {
stopOrbit();
clearTrace();
mode = 'trace';
});
$('orbitBtn').addEventListener('click', function () {
if (mode === 'orbit') {
stopOrbit();
} else {
clearTrace();
mode = 'orbit';
orbitT = 0;
showOrbitPath(true);
this.innerHTML = '&#9209; Stop circular motion';
}
});
$('resetBtn').addEventListener('click', function () {
stopOrbit();
clearTrace();
theta = 80;
$('angleSlider').value = 80;
$('angleVal').innerHTML = '80&#176;';
P = clampInside({ x: 340, y: 195 });
});
$('angleSlider').addEventListener('input', function () {
theta = +this.value;
$('angleVal').innerHTML = theta + '&#176;';
var keepOrbit = mode === 'orbit';
clearTrace();
if (keepOrbit) { mode = 'orbit'; showOrbitPath(true); }
else P = clampInside(P);
});
/* ---------- dragging ---------- */
function svgPoint(evt) {
var p = svg.createSVGPoint();
p.x = evt.clientX; p.y = evt.clientY;
return p.matrixTransform(svg.getScreenCTM().inverse());
}
$('ptP').addEventListener('pointerdown', function (evt) {
dragging = true;
if (mode === 'trace' && !traceDone) clearTrace(); // interrupt a running trace
if (mode === 'orbit') stopOrbit(); // grabbing P ends the orbit
mode = 'free';
this.setPointerCapture(evt.pointerId);
this.setAttribute('cursor', 'grabbing');
evt.preventDefault();
});
$('ptP').addEventListener('pointermove', function (evt) {
if (!dragging) return;
P = clampInside(svgPoint(evt));
});
$('ptP').addEventListener('pointerup', function () {
dragging = false;
this.setAttribute('cursor', 'grab');
});
/* ---------- GIF export ---------- */
GifExport.wireGifButton($('gifBtn'), function () {
recording = true;
var savedP = { x: P.x, y: P.y };
var savedMode = mode, savedT = traceT, savedDone = traceDone;
if (savedMode === 'orbit') {
// record one full revolution of P around the circle
return {
svg: svg,
frames: 72,
delayMs: 100,
width: 640,
filename: 'angle-bisector-converse-orbit.gif',
renderFrame: function (i, n) {
P = orbitPos(i / n);
showOrbitPath(true);
renderScene();
},
onFinish: function () {
recording = false;
orbitT = 0; // orbit resumes from the bisector crossing
}
};
}
clearTrace();
return {
svg: svg,
frames: 100,
delayMs: 90,
width: 640,
filename: 'angle-bisector-converse.gif',
renderFrame: function (i, n) {
renderTrace(Math.min(i / (n - 8), 1)); // ~8 hold frames at the end
},
onFinish: function () {
recording = false;
clearTrace();
P = savedP;
if (savedDone) { // restore a completed trace overlay
mode = savedMode; traceT = savedT; traceDone = savedDone;
renderTrace(1);
$('traceBtn').innerHTML = '&#9654; Trace again';
mode = 'free';
}
}
};
});
buildDots();
P = clampInside(P);
renderScene();
requestAnimationFrame(tick);
})();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Angle Bisectors of a Triangle Meet at a Point — the Incenter</title>
<style>
:root {
--bg: #0f172a;
--card-bg: #1e293b;
--accent-a: #38bdf8;
--accent-b: #f43f5e;
--bisector: #f59e0b;
--perp: #22c55e;
--side: #94a3b8;
--text: #f8fafc;
--text-muted: #94a3b8;
--btn: #334155;
--btn-hover: #475569;
}
* { box-sizing: border-box; margin: 0; padding: 0;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
body {
background-color: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
}
header { text-align: center; max-width: 860px; margin-bottom: 1.5rem; }
h1 { font-size: 1.6rem; margin-bottom: 0.4rem; }
p.subtitle { color: var(--text-muted); font-size: 0.95rem; line-height: 1.4; }
.theorem {
display: inline-block; margin-top: 0.6rem; padding: 0.45rem 1rem;
border: 1px solid #33415577; border-left: 3px solid var(--bisector);
border-radius: 6px; font-size: 0.95rem; color: var(--text);
}
.container {
display: flex; flex-direction: column; gap: 1.5rem;
width: 100%; max-width: 1080px;
}
@media (min-width: 860px) {
.container { display: grid; grid-template-columns: 1fr 340px; }
}
.canvas-card, .side-card {
background-color: var(--card-bg);
border-radius: 12px;
padding: 1rem;
box-shadow: 0 10px 25px -5px rgba(0,0,0,0.3);
}
.canvas-card { display: flex; flex-direction: column; align-items: center; }
svg { width: 100%; height: auto; max-height: 540px; background: var(--bg);
border-radius: 8px; touch-action: none; }
.drag-hint { font-size: 0.8rem; color: var(--text-muted); margin-top: 0.5rem; }
.side-card { display: flex; flex-direction: column; gap: 1rem; padding: 1.3rem; }
.proof-title { color: var(--bisector); font-size: 0.8rem; font-weight: 700;
letter-spacing: 0.08em; text-transform: uppercase; }
ol.proof { list-style: none; display: flex; flex-direction: column; gap: 0.45rem; }
ol.proof li {
font-size: 0.88rem; line-height: 1.45; color: var(--text-muted);
border-left: 3px solid var(--btn); padding: 0.3rem 0 0.3rem 0.7rem;
transition: color 0.2s, border-color 0.2s;
}
ol.proof li.active { color: var(--text); border-color: var(--bisector); }
ol.proof li.done { color: #cbd5e1; }
ol.proof .m { font-family: Cambria, 'STIX Two Math', 'Times New Roman', serif;
font-style: italic; }
.readout {
background: #0f172a; border-radius: 10px; padding: 0.7rem 0.9rem;
display: flex; justify-content: space-around; text-align: center;
font-variant-numeric: tabular-nums;
}
.readout .val { font-size: 1.15rem; font-weight: 700; color: var(--perp); }
.readout .cap { font-size: 0.72rem; color: var(--text-muted); }
.btn-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
button {
background: var(--btn); color: var(--text); border: none;
border-radius: 8px; padding: 0.55rem 0.9rem; font-size: 0.9rem;
cursor: pointer; transition: background 0.15s;
}
button:hover:not(:disabled) { background: var(--btn-hover); }
button:disabled { opacity: 0.55; cursor: default; }
button.primary { background: #0369a1; }
button.primary:hover:not(:disabled) { background: #0284c7; }
button.gold { background: #92600a; }
button.gold:hover:not(:disabled) { background: #b45309; }
footer { margin-top: 1.5rem; color: var(--text-muted); font-size: 0.85rem; }
footer a { color: var(--accent-a); text-decoration: none; margin: 0 0.3rem; }
footer a:hover { text-decoration: underline; }
</style>
</head>
<body>
<header>
<h1>The Angle Bisectors of a Triangle All Meet at a Point</h1>
<p class="subtitle">An animated proof built on the last two pages: the equidistance
theorem and its converse.</p>
<div class="theorem"><b>Theorem.</b>&nbsp; In any <span>&#9651;ABC</span>, the bisectors of
&#8736;A, &#8736;B and &#8736;C are concurrent &#8212; they meet at the incenter D.</div>
</header>
<div class="container">
<div class="canvas-card">
<svg id="scene" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg">
<!-- triangle sides -->
<line id="sideAB" stroke="#94a3b8" stroke-width="3" stroke-linecap="round"/>
<line id="sideBC" stroke="#94a3b8" stroke-width="3" stroke-linecap="round"/>
<line id="sideCA" stroke="#94a3b8" stroke-width="3" stroke-linecap="round"/>
<!-- incircle -->
<circle id="incircle" fill="#f59e0b" fill-opacity="0.07" stroke="#f59e0b"
stroke-width="2" stroke-dasharray="6 6" opacity="0"/>
<!-- bisector cevians -->
<line id="cevA" stroke="#38bdf8" stroke-width="2.5" stroke-linecap="round"/>
<line id="cevB" stroke="#f43f5e" stroke-width="2.5" stroke-linecap="round"/>
<line id="cevC" stroke="#f59e0b" stroke-width="2.5" stroke-linecap="round"/>
<!-- equal-angle marks (populated by script) -->
<g id="marksA"></g>
<g id="marksB"></g>
<g id="marksC"></g>
<!-- perpendiculars from D -->
<line id="perpE" stroke="#22c55e" stroke-width="2.5" stroke-linecap="round"/>
<line id="perpF" stroke="#22c55e" stroke-width="2.5" stroke-linecap="round"/>
<line id="perpG" stroke="#22c55e" stroke-width="2.5" stroke-linecap="round"/>
<line id="tickE" stroke="#22c55e" stroke-width="2"/>
<line id="tickF" stroke="#22c55e" stroke-width="2"/>
<line id="tickG" stroke="#22c55e" stroke-width="2"/>
<polyline id="sqE" fill="none" stroke="#22c55e" stroke-width="1.5" opacity="0.85"/>
<polyline id="sqF" fill="none" stroke="#22c55e" stroke-width="1.5" opacity="0.85"/>
<polyline id="sqG" fill="none" stroke="#22c55e" stroke-width="1.5" opacity="0.85"/>
<!-- points -->
<circle id="ptD" fill="#f59e0b" stroke="#0f172a" stroke-width="1.5"/>
<circle id="ptE" r="0" fill="#22c55e"/>
<circle id="ptF" r="0" fill="#22c55e"/>
<circle id="ptG" r="0" fill="#22c55e"/>
<circle id="vA" r="6" fill="#f8fafc"/>
<circle id="vB" r="6" fill="#f8fafc"/>
<circle id="vC" r="6" fill="#f8fafc"/>
<!-- drag handles -->
<circle id="hitA" r="18" fill="transparent" cursor="grab"/>
<circle id="hitB" r="18" fill="transparent" cursor="grab"/>
<circle id="hitC" r="18" fill="transparent" cursor="grab"/>
<!-- labels -->
<text id="lblA" font-size="18" fill="#f8fafc" font-weight="600" text-anchor="middle">A</text>
<text id="lblB" font-size="18" fill="#f8fafc" font-weight="600" text-anchor="middle">B</text>
<text id="lblC" font-size="18" fill="#f8fafc" font-weight="600" text-anchor="middle">C</text>
<text id="lblD" font-size="17" fill="#f59e0b" font-weight="700">D</text>
<text id="lblE" font-size="15" fill="#22c55e" text-anchor="middle">E</text>
<text id="lblF" font-size="15" fill="#22c55e" text-anchor="middle">F</text>
<text id="lblG" font-size="15" fill="#22c55e" text-anchor="middle">G</text>
<text id="caption" font-size="18" fill="#f59e0b" font-weight="600"
text-anchor="middle" x="400" y="486">&#8756; the three bisectors meet at D &#8212; the incenter of &#9651;ABC</text>
</svg>
<div class="drag-hint">Tip: drag the vertices A, B, C to reshape the triangle at any step.</div>
</div>
<div class="side-card">
<div class="proof-title" id="stepBadge">Proof &#8212; step 1 of 5</div>
<ol class="proof" id="proofList">
<li><span class="m">1.</span>&nbsp; Draw &#9651;ABC.</li>
<li><span class="m">2.</span>&nbsp; Draw the bisectors of &#8736;A and &#8736;B.
Let them meet at a point D.</li>
<li><span class="m">3.</span>&nbsp; Drop perpendiculars from D to the three sides:
DE &#8869; AB, DF &#8869; BC, DG &#8869; CA. By the equidistance theorem
(page&nbsp;2), DE&nbsp;=&nbsp;DF and DE&nbsp;=&nbsp;DG, so
DE&nbsp;=&nbsp;DF&nbsp;=&nbsp;DG.</li>
<li><span class="m">4.</span>&nbsp; Join DC. Since DF&nbsp;=&nbsp;DG, D is
equidistant from sides CB and CA &#8658; by the converse (page&nbsp;3),
DC bisects &#8736;C.</li>
<li><span class="m">5.</span>&nbsp; &#8756; all three angle bisectors pass through
the single point D &#8212; the <b>incenter</b>, center of the inscribed circle
with radius r&nbsp;=&nbsp;DE.</li>
</ol>
<div class="readout">
<div><div class="val" id="deVal">&#8212;</div><div class="cap">DE</div></div>
<div><div class="val" id="dfVal">&#8212;</div><div class="cap">DF</div></div>
<div><div class="val" id="dgVal">&#8212;</div><div class="cap">DG</div></div>
</div>
<div class="btn-row">
<button id="playBtn" class="primary">&#10074;&#10074; Pause</button>
<button id="resetBtn">&#8634; Reset</button>
<button id="prevBtn">&#8592;</button>
<button id="nextBtn">&#8594;</button>
</div>
<div class="btn-row">
<button id="gifBtn" class="gold">&#11015; Download GIF</button>
</div>
</div>
</div>
<footer>
Angle bisector series:
<a href="1-construction.html">1&#183;Construction</a>
<a href="2-equidistance.html">2&#183;Equidistance</a>
<a href="3-converse.html">3&#183;Converse</a>
<a href="4-incenter.html">4&#183;Incenter</a>
</footer>
<script src="gif-export.js"></script>
<script>
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
var svg = $('scene');
var NS = 'http://www.w3.org/2000/svg';
var A = { x: 150, y: 425 };
var B = { x: 655, y: 425 };
var C = { x: 420, y: 80 };
var DUR = 11000, HOLD = 2000;
var PH = [0, 0.13, 0.33, 0.60, 0.82, 1.00];
var T = 0, playing = true, recording = false, last = null;
var dragging = null;
/* ---------- geometry ---------- */
function dist(P, Q) { return Math.hypot(Q.x - P.x, Q.y - P.y); }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function ease(v) { return v * v * (3 - 2 * v); }
function lerpPt(P, Q, t) { return { x: P.x + (Q.x - P.x) * t, y: P.y + (Q.y - P.y) * t }; }
function ang(V, P) { return Math.atan2(-(P.y - V.y), P.x - V.x) * 180 / Math.PI; }
function incenter() {
var a = dist(B, C), b = dist(C, A), c = dist(A, B);
var s = a + b + c;
return { x: (a * A.x + b * B.x + c * C.x) / s,
y: (a * A.y + b * B.y + c * C.y) / s };
}
function project(P, U, V) { // foot of the perpendicular from P onto line UV
var ux = V.x - U.x, uy = V.y - U.y;
var t = ((P.x - U.x) * ux + (P.y - U.y) * uy) / (ux * ux + uy * uy);
return { x: U.x + t * ux, y: U.y + t * uy };
}
function lineHit(P, Q, U, V) { // intersection of line PQ with line UV
var d1x = Q.x - P.x, d1y = Q.y - P.y, d2x = V.x - U.x, d2y = V.y - U.y;
var den = d1x * d2y - d1y * d2x;
var t = ((U.x - P.x) * d2y - (U.y - P.y) * d2x) / den;
return { x: P.x + t * d1x, y: P.y + t * d1y };
}
function arcPath(c, r, a0, a1) { // math angles in degrees
if (Math.abs(a1 - a0) < 0.05) return '';
var p0 = { x: c.x + r * Math.cos(a0 * Math.PI / 180), y: c.y - r * Math.sin(a0 * Math.PI / 180) };
var p1 = { x: c.x + r * Math.cos(a1 * Math.PI / 180), y: c.y - r * Math.sin(a1 * Math.PI / 180) };
var large = Math.abs(a1 - a0) > 180 ? 1 : 0;
var sweep = a1 > a0 ? 0 : 1;
return 'M ' + p0.x.toFixed(2) + ' ' + p0.y.toFixed(2) +
' A ' + r + ' ' + r + ' 0 ' + large + ' ' + sweep + ' ' +
p1.x.toFixed(2) + ' ' + p1.y.toFixed(2);
}
// arc between two directions, taking the short way around
function arcBetween(V, aFrom, aTo, r) {
var d = ((aTo - aFrom + 540) % 360) - 180;
return arcPath(V, r, aFrom, aFrom + d);
}
function setLine(el, a, b) {
el.setAttribute('x1', a.x); el.setAttribute('y1', a.y);
el.setAttribute('x2', b.x); el.setAttribute('y2', b.y);
}
function place(el, x, y) { el.setAttribute('x', x); el.setAttribute('y', y); }
function centroid() { return { x: (A.x + B.x + C.x) / 3, y: (A.y + B.y + C.y) / 3 }; }
function outward(V, d) { // point pushed d px away from the triangle's center
var g = centroid();
var L = dist(V, g) || 1;
return { x: V.x + (V.x - g.x) / L * d, y: V.y + (V.y - g.y) / L * d };
}
function squarePts(F, u, v, size) {
var a = { x: F.x - u.x * size, y: F.y - u.y * size };
var b = { x: a.x + v.x * size, y: a.y + v.y * size };
var c = { x: F.x + v.x * size, y: F.y + v.y * size };
return a.x.toFixed(1) + ',' + a.y.toFixed(1) + ' ' +
b.x.toFixed(1) + ',' + b.y.toFixed(1) + ' ' +
c.x.toFixed(1) + ',' + c.y.toFixed(1);
}
function perpTick(el, P, Q) { // small equality tick crossing segment PQ at its midpoint
var mx = (P.x + Q.x) / 2, my = (P.y + Q.y) / 2;
var L = dist(P, Q) || 1;
var nx = -(Q.y - P.y) / L, ny = (Q.x - P.x) / L;
setLine(el, { x: mx - 5 * nx, y: my - 5 * ny }, { x: mx + 5 * nx, y: my + 5 * ny });
}
/* ---------- equal-angle marks: n concentric arcs per half-angle ---------- */
function makeArcs(groupId, n, color) {
var g = $(groupId), arr = [];
for (var k = 0; k < 2 * n; k++) {
var p = document.createElementNS(NS, 'path');
p.setAttribute('fill', 'none');
p.setAttribute('stroke', color);
p.setAttribute('stroke-width', '1.8');
g.appendChild(p);
arr.push(p);
}
return arr;
}
var arcsA = makeArcs('marksA', 1, '#38bdf8');
var arcsB = makeArcs('marksB', 2, '#f43f5e');
var arcsC = makeArcs('marksC', 3, '#f59e0b');
// update a vertex's marks: n arcs between side1-dir and D-dir, n between D-dir and side2-dir
function updateMarks(arcs, n, V, P1, D, P2, opacity) {
var a1 = ang(V, P1), aD = ang(V, D), a2 = ang(V, P2);
for (var i = 0; i < n; i++) {
var r = 24 + 6 * i;
arcs[i].setAttribute('d', arcBetween(V, a1, aD, r));
arcs[n + i].setAttribute('d', arcBetween(V, aD, a2, r));
}
for (var k = 0; k < 2 * n; k++) arcs[k].setAttribute('opacity', opacity);
}
/* ---------- rendering ---------- */
function curPhase(t) {
for (var i = 4; i >= 0; i--) if (t >= PH[i]) return Math.min(i, 4);
return 0;
}
function render(t) {
var lp = function (i) { return clamp01((t - PH[i]) / (PH[i + 1] - PH[i])); };
var D = incenter();
var XA = lineHit(A, D, B, C); // bisector from A meets BC
var XB = lineHit(B, D, A, C); // bisector from B meets CA
var E = project(D, A, B), F = project(D, B, C), G = project(D, C, A);
var r = dist(D, E);
// phase 0: sides draw in sequence
var p0 = lp(0);
setLine($('sideAB'), A, lerpPt(A, B, ease(clamp01(p0 * 3))));
setLine($('sideBC'), B, lerpPt(B, C, ease(clamp01(p0 * 3 - 1))));
setLine($('sideCA'), C, lerpPt(C, A, ease(clamp01(p0 * 3 - 2))));
var la = outward(A, 22), lb = outward(B, 22), lc = outward(C, 22);
place($('lblA'), la.x, la.y + 6); place($('lblB'), lb.x, lb.y + 6); place($('lblC'), lc.x, lc.y + 6);
['vA', 'vB', 'vC', 'hitA', 'hitB', 'hitC'].forEach(function (id) {
var V = { vA: A, vB: B, vC: C, hitA: A, hitB: B, hitC: C }[id];
$(id).setAttribute('cx', V.x); $(id).setAttribute('cy', V.y);
});
// phase 1: bisectors from A then B; D pops where they cross
var pA = t > PH[1] ? ease(clamp01(lp(1) * 2)) : 0;
var pB = t > PH[1] ? ease(clamp01(lp(1) * 2 - 1)) : 0;
setLine($('cevA'), A, lerpPt(A, XA, pA));
setLine($('cevB'), B, lerpPt(B, XB, pB));
updateMarks(arcsA, 1, A, B, D, C, pA >= 1 ? 1 : 0);
updateMarks(arcsB, 2, B, C, D, A, pB >= 1 ? 1 : 0);
var dFrac = dist(B, D) / dist(B, XB);
var dGrow = clamp01((pB - dFrac) / 0.15);
$('ptD').setAttribute('r', 5.5 * ease(dGrow));
$('ptD').setAttribute('cx', D.x); $('ptD').setAttribute('cy', D.y);
$('lblD').setAttribute('opacity', dGrow >= 1 ? 1 : 0);
place($('lblD'), D.x + 10, D.y - 9);
// phase 2: the three perpendiculars DE, DF, DG in sequence
var p2 = t > PH[2] ? lp(2) : 0;
var feet = [
{ seg: 'perpE', tick: 'tickE', sq: 'sqE', pt: 'ptE', lbl: 'lblE', foot: E, side: [A, B] },
{ seg: 'perpF', tick: 'tickF', sq: 'sqF', pt: 'ptF', lbl: 'lblF', foot: F, side: [B, C] },
{ seg: 'perpG', tick: 'tickG', sq: 'sqG', pt: 'ptG', lbl: 'lblG', foot: G, side: [C, A] }
];
feet.forEach(function (o, i) {
var pp = ease(clamp01(p2 * 3 - i));
var end = lerpPt(D, o.foot, pp);
setLine($(o.seg), D, end);
var done = pp >= 1;
$(o.pt).setAttribute('cx', o.foot.x); $(o.pt).setAttribute('cy', o.foot.y);
$(o.pt).setAttribute('r', done ? 3.5 : 0);
if (done) {
perpTick($(o.tick), D, o.foot);
var U = o.side[0], V = o.side[1];
var L = dist(U, V);
var u = { x: (V.x - U.x) / L, y: (V.y - U.y) / L };
var dd = dist(o.foot, D) || 1;
var v = { x: (D.x - o.foot.x) / dd, y: (D.y - o.foot.y) / dd };
$(o.sq).setAttribute('points', squarePts(o.foot, u, v, 9));
var away = { x: o.foot.x + (o.foot.x - D.x) / dd * 16, y: o.foot.y + (o.foot.y - D.y) / dd * 16 };
place($(o.lbl), away.x, away.y + 5);
} else {
$(o.tick).setAttribute('x1', 0); $(o.tick).setAttribute('y1', 0);
$(o.tick).setAttribute('x2', 0); $(o.tick).setAttribute('y2', 0);
$(o.sq).setAttribute('points', '');
}
$(o.lbl).setAttribute('opacity', done ? 1 : 0);
});
var units = (r / 10).toFixed(1);
$('deVal').innerHTML = p2 * 3 >= 1 ? units : '&#8212;';
$('dfVal').innerHTML = p2 * 3 >= 2 ? units : '&#8212;';
$('dgVal').innerHTML = p2 >= 1 ? units : '&#8212;';
// phase 3: join DC, then mark the two equal halves of ∠C
var p3 = t > PH[3] ? ease(lp(3)) : 0;
setLine($('cevC'), C, lerpPt(C, D, clamp01(p3 * 1.4)));
updateMarks(arcsC, 3, C, B, D, A, clamp01((p3 - 0.7) / 0.3));
// phase 4: the incircle and the conclusion
var p4 = t > PH[4] ? ease(lp(4)) : 0;
$('incircle').setAttribute('cx', D.x); $('incircle').setAttribute('cy', D.y);
$('incircle').setAttribute('r', Math.max(r * p4, 0.01));
$('incircle').setAttribute('opacity', p4);
$('caption').setAttribute('opacity', p4);
// proof panel highlighting
var ph = curPhase(Math.min(t, 0.999));
$('stepBadge').innerHTML = 'Proof &#8212; step ' + (ph + 1) + ' of 5';
var items = $('proofList').children;
for (var i = 0; i < items.length; i++) {
items[i].className = i === ph ? 'active' : (i < ph ? 'done' : '');
}
}
/* ---------- playback ---------- */
function tick(ts) {
if (last === null) last = ts;
var dt = Math.min(ts - last, 100);
last = ts;
if (playing && !recording && !dragging) {
T += dt;
if (T > DUR + HOLD) T = 0;
}
if (!recording) render(Math.min(T / DUR, 1));
requestAnimationFrame(tick);
}
function setPlaying(v) {
playing = v;
$('playBtn').innerHTML = v ? '&#10074;&#10074; Pause' : '&#9654; Play';
}
$('playBtn').addEventListener('click', function () { setPlaying(!playing); });
$('resetBtn').addEventListener('click', function () {
T = 0;
A = { x: 150, y: 425 }; B = { x: 655, y: 425 }; C = { x: 420, y: 80 };
setPlaying(true);
});
function stepJump(di) {
setPlaying(false);
var t = Math.min(T / DUR, 1);
var i = curPhase(Math.min(t, 0.999));
var atEnd = t >= PH[i + 1] - 0.005;
var target = di > 0 ? (atEnd ? i + 1 : i) : i - 1;
target = Math.max(0, Math.min(4, target));
T = (PH[target + 1] - 0.0001) * DUR;
}
$('prevBtn').addEventListener('click', function () { stepJump(-1); });
$('nextBtn').addEventListener('click', function () { stepJump(1); });
/* ---------- vertex dragging ---------- */
function svgPoint(evt) {
var p = svg.createSVGPoint();
p.x = evt.clientX; p.y = evt.clientY;
return p.matrixTransform(svg.getScreenCTM().inverse());
}
function clampVertex(p) {
return { x: Math.min(Math.max(p.x, 40), 760), y: Math.min(Math.max(p.y, 40), 460) };
}
[['hitA', function (p) { A = p; }], ['hitB', function (p) { B = p; }],
['hitC', function (p) { C = p; }]].forEach(function (pair) {
var el = $(pair[0]), setV = pair[1];
el.addEventListener('pointerdown', function (evt) {
dragging = pair[0];
el.setPointerCapture(evt.pointerId);
evt.preventDefault();
});
el.addEventListener('pointermove', function (evt) {
if (dragging !== pair[0]) return;
setV(clampVertex(svgPoint(evt)));
});
el.addEventListener('pointerup', function () { dragging = null; });
});
/* ---------- GIF export ---------- */
GifExport.wireGifButton($('gifBtn'), function () {
var wasPlaying = playing;
recording = true;
playing = false;
return {
svg: svg,
frames: 100,
delayMs: 100,
width: 640,
filename: 'angle-bisector-incenter.gif',
renderFrame: function (i, n) {
render(Math.min(i / (n - 10), 1)); // hold frames on the finished proof
},
onFinish: function () {
recording = false;
setPlaying(wasPlaying);
}
};
});
render(0);
requestAnimationFrame(tick);
})();
</script>
</body>
</html>

Angle Bisector — Interactive Animations

Four self-contained HTML5 + JavaScript animations that build up the angle bisector story, ending with a full animated proof that the three angle bisectors of a triangle are concurrent (the incenter).

Files

File What it shows
1-construction.html The classic compass-and-straightedge construction of an angle bisector, animated in 6 narrated steps.
2-equidistance.html Equidistance theorem — a point on the bisector is equidistant from both sides. Drag P along the bisector and watch d₁ = d₂.
3-converse.html Converse — a point equidistant from both sides lies on the bisector. Free-drag P, trace the locus of equal points, or send P around a circle that pauses at the two equidistant crossings.
4-incenter.html The payoff — animated 5-step proof that the bisectors of △ABC meet at one point D, using the theorem (page 2) and its converse (page 3). Vertices are draggable.
gif-export.js Shared helper used by all four pages: records the animation and encodes an animated GIF entirely in the browser.
mathjax-bookmarklet.txt The "start MathJax" bookmarklet — for the blog post only, see below.

How to use

  1. Download all the files of this gist into one folder (the pages load gif-export.js from the same directory).
  2. Open any of the .html files in a browser — just double-click; they work straight from file://. No server, no build step, no internet connection, no external libraries.
  3. The footer of every page links to the other three.

Controls (all pages)

  • Play / Pause / Reset — the animations loop on their own.
  • ← / → step buttons (pages 1 and 4) — walk through the construction / proof one step at a time.
  • Angle slider (pages 1–3) — reshape the angle live at any point.
  • Drag the points — P on pages 2–3, the triangle vertices A, B, C on page 4. Everything re-renders live.
  • ⬇ Download GIF — records one full loop of the current animation and downloads it as an animated .gif (on page 3 it records the orbit if the circular motion is running, otherwise the trace). Keep the tab visible while it records.

MathJax bookmarklet (blog post only)

mathjax-bookmarklet.txt contains the start MathJax bookmarklet. It is only needed for the blog post, where the written proof uses TeX notation ($\triangle ABC$, $\angle A$, $DE \perp AB$, …) — clicking the bookmarklet loads MathJax from a CDN and typesets the math on that page.

The animation pages in this gist do not need it: they are fully self-contained and use plain Unicode math symbols.

To install it: create a new bookmark in your browser, give it a name like "start MathJax", and paste the entire contents of mathjax-bookmarklet.txt into the URL field. Then, on the blog post, click the bookmark once to render the math.

/*
* gif-export.js — record an SVG animation into an animated GIF, fully client-side.
*
* Usage:
* GifExport.wireGifButton(buttonEl, () => ({
* svg, // the <svg> element to capture
* frames: 60, // number of frames
* delayMs: 100, // per-frame delay
* width: 640, // output pixel width (height from viewBox aspect)
* background: '#0f172a', // painted behind the SVG
* renderFrame: (i, n) => {}, // set animation state for frame i of n (deterministic)
* filename: 'anim.gif',
* onFinish: () => {} // called after download or failure (resume playback here)
* }));
*
* No external dependencies; works from file://.
*/
(function () {
'use strict';
/* ---------- SVG rasterization ---------- */
// CSS from the host page does not apply to an SVG rendered as an <img>,
// so computed styles are inlined onto a clone before serialization.
var STYLE_PROPS = [
'fill', 'fill-opacity', 'fill-rule',
'stroke', 'stroke-opacity', 'stroke-width', 'stroke-dasharray',
'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit',
'opacity', 'display', 'visibility',
'font-family', 'font-size', 'font-weight', 'font-style',
'letter-spacing', 'text-anchor', 'dominant-baseline', 'paint-order'
];
function inlineStyles(srcRoot, dstRoot) {
var src = [srcRoot].concat(Array.prototype.slice.call(srcRoot.querySelectorAll('*')));
var dst = [dstRoot].concat(Array.prototype.slice.call(dstRoot.querySelectorAll('*')));
for (var i = 0; i < src.length; i++) {
var cs = getComputedStyle(src[i]);
var text = '';
for (var j = 0; j < STYLE_PROPS.length; j++) {
var v = cs.getPropertyValue(STYLE_PROPS[j]);
if (v) text += STYLE_PROPS[j] + ':' + v + ';';
}
dst[i].setAttribute('style', text);
}
}
function svgToImage(svgEl, width, height) {
var clone = svgEl.cloneNode(true);
inlineStyles(svgEl, clone);
clone.setAttribute('width', width);
clone.setAttribute('height', height);
var xml = new XMLSerializer().serializeToString(clone);
var blob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' });
var url = URL.createObjectURL(blob);
return new Promise(function (resolve, reject) {
var img = new Image();
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('SVG rasterization failed')); };
img.src = url;
});
}
/* ---------- color quantization ---------- */
// Popularity quantization on 15-bit color bins. The scenes are flat
// dark-theme colors plus antialiasing, so 256 sampled colors are plenty.
function buildPalette(frameDatas) {
var bins = new Map();
var step = Math.max(1, Math.floor(frameDatas.length / 6));
for (var f = 0; f < frameDatas.length; f += step) {
var d = frameDatas[f];
for (var i = 0; i < d.length; i += 4) {
var key = ((d[i] >> 3) << 10) | ((d[i + 1] >> 3) << 5) | (d[i + 2] >> 3);
var b = bins.get(key);
if (!b) { b = [0, 0, 0, 0]; bins.set(key, b); }
b[0] += d[i]; b[1] += d[i + 1]; b[2] += d[i + 2]; b[3]++;
}
}
var sorted = Array.from(bins.values()).sort(function (a, b) { return b[3] - a[3]; }).slice(0, 256);
var palette = new Uint8Array(768);
for (var k = 0; k < sorted.length; k++) {
palette[k * 3] = Math.round(sorted[k][0] / sorted[k][3]);
palette[k * 3 + 1] = Math.round(sorted[k][1] / sorted[k][3]);
palette[k * 3 + 2] = Math.round(sorted[k][2] / sorted[k][3]);
}
return { palette: palette, count: sorted.length };
}
function makeMapper(palette, count) {
var cache = new Map();
return function (r, g, b) {
var key = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3);
var idx = cache.get(key);
if (idx !== undefined) return idx;
var best = 0, bestD = Infinity;
for (var i = 0; i < count; i++) {
var dr = r - palette[i * 3], dg = g - palette[i * 3 + 1], db = b - palette[i * 3 + 2];
var dist = dr * dr + dg * dg + db * db;
if (dist < bestD) { bestD = dist; best = i; }
}
cache.set(key, best);
return best;
};
}
/* ---------- byte stream ---------- */
function ByteBuffer() {
this.data = new Uint8Array(1 << 16);
this.len = 0;
}
ByteBuffer.prototype.ensure = function (n) {
while (this.len + n > this.data.length) {
var d = new Uint8Array(this.data.length * 2);
d.set(this.data);
this.data = d;
}
};
ByteBuffer.prototype.byte = function (b) { this.ensure(1); this.data[this.len++] = b & 0xff; };
ByteBuffer.prototype.bytes = function (arr) { this.ensure(arr.length); this.data.set(arr, this.len); this.len += arr.length; };
ByteBuffer.prototype.short = function (v) { this.byte(v & 0xff); this.byte((v >> 8) & 0xff); };
ByteBuffer.prototype.string = function (s) { for (var i = 0; i < s.length; i++) this.byte(s.charCodeAt(i)); };
ByteBuffer.prototype.toUint8 = function () { return this.data.subarray(0, this.len); };
/* ---------- GIF89a encoding ---------- */
function writeLzw(buf, indices) {
var minCodeSize = 8;
buf.byte(minCodeSize);
var clearCode = 1 << minCodeSize; // 256
var eoiCode = clearCode + 1; // 257
var codeSize, dict, nextCode;
var out = [];
var cur = 0, curBits = 0;
function emit(code) {
cur |= code << curBits;
curBits += codeSize;
while (curBits >= 8) { out.push(cur & 0xff); cur >>= 8; curBits -= 8; }
}
function reset() {
dict = new Map();
nextCode = eoiCode + 1;
codeSize = minCodeSize + 1;
}
reset();
emit(clearCode);
var prefix = indices[0];
for (var i = 1; i < indices.length; i++) {
var k = indices[i];
var key = (prefix << 8) | k;
var found = dict.get(key);
if (found !== undefined) { prefix = found; continue; }
emit(prefix);
if (nextCode === 4096) {
emit(clearCode);
reset();
} else {
dict.set(key, nextCode++);
if (nextCode === (1 << codeSize) + 1 && codeSize < 12) codeSize++;
}
prefix = k;
}
emit(prefix);
emit(eoiCode);
if (curBits > 0) out.push(cur & 0xff);
for (var p = 0; p < out.length; p += 255) {
var n = Math.min(255, out.length - p);
buf.byte(n);
buf.bytes(out.slice(p, p + n));
}
buf.byte(0); // block terminator
}
function GifBuilder(width, height, palette) {
this.w = width;
this.h = height;
var buf = this.buf = new ByteBuffer();
buf.string('GIF89a');
buf.short(width);
buf.short(height);
buf.byte(0xF7); // global color table, 8 bits/channel, 256 entries
buf.byte(0); // background color index
buf.byte(0); // pixel aspect ratio
buf.bytes(palette); // 768 bytes
// NETSCAPE looping extension (loop forever)
buf.byte(0x21); buf.byte(0xFF); buf.byte(0x0B);
buf.string('NETSCAPE2.0');
buf.byte(0x03); buf.byte(0x01); buf.short(0); buf.byte(0);
}
GifBuilder.prototype.addFrame = function (indices, delayCs) {
var buf = this.buf;
// graphic control extension
buf.byte(0x21); buf.byte(0xF9); buf.byte(0x04);
buf.byte(0x04); // disposal: do not dispose
buf.short(delayCs);
buf.byte(0); // transparent color index (unused)
buf.byte(0); // terminator
// image descriptor
buf.byte(0x2C);
buf.short(0); buf.short(0);
buf.short(this.w); buf.short(this.h);
buf.byte(0); // no local color table
writeLzw(buf, indices);
};
GifBuilder.prototype.finish = function () {
this.buf.byte(0x3B);
return this.buf.toUint8();
};
/* ---------- recorder ---------- */
// Yield to the event loop between encoded frames so progress text can paint.
// MessageChannel is used instead of setTimeout because background tabs
// throttle timers to ~1s, which would stall the encode.
var nextTick = (function () {
var ch = typeof MessageChannel !== 'undefined' ? new MessageChannel() : null;
var queue = [];
if (ch) ch.port1.onmessage = function () { var f = queue.shift(); if (f) f(); };
return function () {
return new Promise(function (resolve) {
if (ch) { queue.push(resolve); ch.port2.postMessage(0); }
else setTimeout(resolve, 0);
});
};
})();
function record(opts) {
var svg = opts.svg;
var frames = opts.frames;
var renderFrame = opts.renderFrame;
var delayMs = opts.delayMs || 100;
var width = opts.width || 640;
var background = opts.background || '#0f172a';
var onProgress = opts.onProgress || function () {};
var filename = opts.filename || 'animation.gif';
var vb = svg.viewBox.baseVal;
var height = Math.round(width * vb.height / vb.width);
var canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
var ctx = canvas.getContext('2d', { willReadFrequently: true });
var frameDatas = [];
function captureLoop(i) {
if (i >= frames) return Promise.resolve();
renderFrame(i, frames);
return svgToImage(svg, width, height).then(function (img) {
ctx.fillStyle = background;
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
frameDatas.push(ctx.getImageData(0, 0, width, height).data);
onProgress('capture', i + 1, frames);
return captureLoop(i + 1);
});
}
return captureLoop(0).then(function () {
var q = buildPalette(frameDatas);
var mapper = makeMapper(q.palette, q.count);
var gif = new GifBuilder(width, height, q.palette);
var delayCs = Math.max(2, Math.round(delayMs / 10));
var indices = new Uint8Array(width * height);
function encodeLoop(f) {
if (f >= frames) return Promise.resolve();
var d = frameDatas[f];
for (var i = 0, p = 0; i < d.length; i += 4, p++) {
indices[p] = mapper(d[i], d[i + 1], d[i + 2]);
}
gif.addFrame(indices, delayCs);
onProgress('encode', f + 1, frames);
if (f % 6 === 5) {
return nextTick().then(function () { return encodeLoop(f + 1); });
}
return encodeLoop(f + 1);
}
return encodeLoop(0).then(function () {
var blob = new Blob([gif.finish()], { type: 'image/gif' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 5000);
});
});
}
function wireGifButton(button, getOptions) {
var original = button.textContent;
button.addEventListener('click', function () {
if (button.disabled) return;
button.disabled = true;
var opts = null;
try {
opts = getOptions();
} catch (err) {
button.disabled = false;
throw err;
}
opts.onProgress = function (phase, done, total) {
button.textContent = (phase === 'capture' ? 'Recording ' : 'Encoding ') + done + '/' + total;
};
record(opts).catch(function (err) {
console.error(err);
alert('GIF export failed: ' + err.message);
}).then(function () {
button.disabled = false;
button.textContent = original;
if (opts.onFinish) opts.onFinish();
});
});
}
window.GifExport = { record: record, wireGifButton: wireGifButton };
})();
javascript:(function(){if(window.MathJax===undefined){var%20script%20=%20document.createElement("script");script.type%20=%20"text/javascript";script.src%20=%20"https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js?config=TeX-AMS_HTML-full%22;var%20config%20=%20%27MathJax.Hub.Config({%27%20+%20%27extensions:%20[%22tex2jax.js%22],%27%20+%20%27tex2jax:%20{%20inlineMath:%20[[%22$%22,%22$%22],[%22\\\\\\\\\\\\(%22,%22\\\\\\\\\\\\)%22]],%20displayMath:%20[[%22$$%22,%22$$%22],[%22\\\\[%22,%22\\\\]%22]],%20processEscapes:%20true%20},%27%20+%20%27jax:%20[%22input/TeX%22,%22output/HTML-CSS%22]%27%20+%20%27});%27%20+%20%27MathJax.Hub.Startup.onload();%27;if%20(window.opera)%20{script.innerHTML%20=%20config}%20else%20{script.text%20=%20config}%20document.getElementsByTagName(%22head%22)[0].appendChild(script);(doChatJax=function(){window.setTimeout(doChatJax,1000);MathJax.Hub.Queue([%22Typeset%22,MathJax.Hub]);})();}else{MathJax.Hub.Queue([%22Typeset%22,MathJax.Hub]);}})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment