Besides being able to set the arc radius with the slider, the initial point
P0 and control points P1 and
P2 can be moved by dragging them with the mouse with the
left button down. The numeric values can also be edited, and the arrow keys
can be used to change an underlined element that is in focus.
<div>
<label for="arc-radius">arc radius <em>r</em></label>
<input name="arc-radius" type="range" id="radius-slider" min="0" />
<label
for="arc-radius"
id="value-r"
class="input"
contenteditable="true"></label>
</div>
<div>
<span id="value-P0" class="input" tabindex="0">
<em>P<sub>0</sub></em>
</span>
= (<span id="value-P0x" class="input" contenteditable="true"></span>,
<span id="value-P0y" class="input" contenteditable="true"></span>)
<span id="value-P1" class="input" tabindex="0">
<em>P<sub>1</sub></em>
</span>
= (<span id="value-P1x" class="input" contenteditable="true"></span>,
<span id="value-P1y" class="input" contenteditable="true"></span>)
<span id="value-P2" class="input" tabindex="0">
<em>P<sub>2</sub></em>
</span>
= (<span id="value-P2x" class="input" contenteditable="true"></span>,
<span id="value-P2y" class="input" contenteditable="true"></span>)
</div>
<canvas id="canvas"></canvas>
<div>
<em>T<sub>1</sub></em> = <span id="value-T1"></span>
</div>
<div>
<em>T<sub>2</sub></em> = <span id="value-T2"></span>
</div>
<div><em>C</em> = <span id="value-C"></span></div>
<script>
"use strict";
const param = {
canvasWidth: 300,
canvasHeight: 300,
hitDistance: 5,
errorTolCenter: 1e-4,
radiusMax: 250,
P0x: 50,
P0y: 50,
P1x: 275,
P1y: 150,
P2x: 50,
P2y: 275,
radius: 75,
};
class Math2D {
static point(x = 0, y = 0) {
return { x: x, y: y };
}
static vector(x = 0, y = 0) {
return this.point(x, y);
}
static subtract(difference, minuend, subtrahend) {
difference.x = minuend.x - subtrahend.x;
difference.y = minuend.y - subtrahend.y;
}
static L2(a) {
return Math.hypot(a.x, a.y);
}
static dot(a, b) {
return a.x * b.x + a.y * b.y;
}
static linePointAt(P0, t, dir) {
return this.point(P0.x + t * dir.x, P0.y + t * dir.y);
}
}
class TextInput {
#valueMax;
#callbackKeydown;
#callbackFocus;
static mo = new MutationObserver(TextInput.processInput);
static moOptions = {
subtree: true,
characterData: true,
};
static symbolTextInput = Symbol("textInput");
static processInput(mrs, mo) {
const textInput = mo[TextInput.symbolTextInput];
for (let i = 0, n = mrs.length; i < n; i++) {
const mr = mrs[i];
if (mr.type === "characterData") {
const target = mr.target;
if (target.nodeType !== 3) {
console.error(
"Mutation record type CharacterData but " +
"node type = " +
target.nodeType,
);
return;
}
let value = parseInt(target.textContent);
value = isNaN(value) ? 0 : value;
textInput.updateFull(value);
break;
}
}
}
constructor(
idText,
idControl,
valueMax,
getStateValue,
setStateValue,
) {
this.#valueMax = valueMax;
this.elementText = document.getElementById(idText);
this.elementControl =
idControl === null ? null : document.getElementById(idControl);
this.getStateValue = getStateValue;
this.setStateValue = setStateValue;
this.#callbackKeydown = (evt) => {
let valueInput;
switch (evt.code) {
case "Enter":
evt.preventDefault();
return;
case "ArrowUp":
valueInput = Number(this.elementText.textContent) + 1;
evt.preventDefault();
break;
case "ArrowDown":
valueInput = Number(this.elementText.textContent) - 1;
evt.preventDefault();
break;
default:
return;
}
TextInput.mo.disconnect();
this.updateFull(valueInput);
const options = { subtree: true, characterData: true };
TextInput.mo.observe(this.elementText, TextInput.moOptions);
};
this.#callbackFocus = (evt) => {
TextInput.mo[TextInput.symbolTextInput] = this;
TextInput.mo.observe(this.elementText, TextInput.moOptions);
this.elementText.addEventListener("keydown", this.#callbackKeydown);
this.elementText.addEventListener("blur", () => {
this.elementText.removeEventListener(
"keydown",
this.#callbackKeydown,
);
TextInput.mo.disconnect();
});
};
this.elementText.addEventListener("focus", this.#callbackFocus);
}
updateFull(value) {
if (value > this.#valueMax) {
value = this.#valueMax;
} else if (value < 0) {
value = 0;
}
const valueTextPrev = this.elementText.textContent;
const valueString = String(value);
if (valueTextPrev !== valueString) {
this.elementText.textContent = valueString;
}
if (this.elementControl) {
const valueControlPrev = this.elementControl.value;
if (valueControlPrev !== valueString) {
this.elementControl.value = valueString;
}
}
const valueStatePrev = this.getStateValue();
if (valueStatePrev !== value) {
this.setStateValue(value);
updateResults();
}
}
}
function initDemoState({
canvasWidth = 300,
canvasHeight = 300,
hitDistance = 5,
errorTolCenter = 1e-4,
radiusMax = 250,
P0x = 0,
P0y = 0,
P1x = 0,
P1y = 0,
P2x = 0,
P2y = 0,
radius = 0,
} = {}) {
const s = {};
s.controlPoints = [
Math2D.point(P0x, P0y),
Math2D.point(P1x, P1y),
Math2D.point(P2x, P2y),
];
s.hitDistance = hitDistance;
s.errorTolCenter = errorTolCenter;
s.canvasSize = Math2D.point(canvasWidth, canvasHeight);
if (radius > radiusMax) {
radius = radiusMax;
}
s.radius = radius;
s.radiusMax = radiusMax;
[s.haveCircle, s.P0Inf, s.P2Inf, s.T1, s.T2, s.C] = findConstruction(
s.controlPoints,
s.radius,
s.canvasSize,
s.errorTolCenter,
);
s.pointActiveIndex = -1;
s.pointActiveMoving = false;
s.mouseDelta = Math2D.point();
return s;
}
function updateResults() {
updateConstruction();
drawCanvas();
ConstructionPoints.print(state.T1, state.T2, state.C);
}
function updateConstruction() {
[state.haveCircle, state.P0Inf, state.P2Inf, state.T1, state.T2, state.C] =
findConstruction(
state.controlPoints,
state.radius,
state.canvasSize,
state.errorTolCenter,
);
}
function findConstruction([P0, P1, P2], r, canvasSize, errorTolCenter) {
function findCenter(T, d, r, dirTan) {
const dn =
Math.abs(d.x) < Math.abs(d.y)
? Math2D.point(1, -d.x / d.y)
: Math2D.point(-d.y / d.x, 1);
if (Math2D.dot(dn, dirTan) < 0) {
dn.x = -dn.x;
dn.y = -dn.y;
}
return Math2D.linePointAt(T, r / Math2D.L2(dn), dn);
}
const dir1 = Math2D.vector(P0.x - P1.x, P0.y - P1.y);
if (dir1.x === 0 && dir1.y === 0) {
return [false];
}
const dir2 = Math2D.vector(P2.x - P1.x, P2.y - P1.y);
if (dir2.x === 0 && dir2.y === 0) {
return [false];
}
const dir1Mag = Math2D.L2(dir1);
const dir2Mag = Math2D.L2(dir2);
const dir1_unit = Math2D.vector(dir1.x / dir1Mag, dir1.y / dir1Mag);
const dir2_unit = Math2D.vector(dir2.x / dir2Mag, dir2.y / dir2Mag);
const dp = Math2D.dot(dir1_unit, dir2_unit);
if (Math.abs(dp) > 0.999999) {
return [false];
}
const angle = Math.acos(Math2D.dot(dir1_unit, dir2_unit));
const distToTangent = r / Math.tan(0.5 * angle);
const T1 = Math2D.linePointAt(P1, distToTangent, dir1_unit);
const T2 = Math2D.linePointAt(P1, distToTangent, dir2_unit);
const dirT2_T1 = Math2D.vector(T2.x - T1.x, T2.y - T1.y);
const dirT1_T2 = Math2D.vector(-dirT2_T1.x, -dirT2_T1.y);
const C1 = findCenter(T1, dir1_unit, r, dirT2_T1);
const C2 = findCenter(T2, dir2_unit, r, dirT1_T2);
const deltaC = Math2D.vector(C2.x - C1.x, C2.y - C1.y);
if (deltaC.x * deltaC.x + deltaC.y * deltaC.y > errorTolCenter) {
console.error(
`Programming or numerical error, ` +
`P0(${P0.x},${P0.y}); ` +
`P1(${P1.x},${P1.y}); ` +
`P2(${P2.x},${P2.y}); ` +
`r=${r};`,
);
}
const C = Math2D.point(C1.x + 0.5 * deltaC.x, C1.y + 0.5 * deltaC.y);
const distToInf = canvasSize.x + canvasSize.y;
const L1inf = Math2D.linePointAt(P1, distToInf, dir1_unit);
const L2inf = Math2D.linePointAt(P1, distToInf, dir2_unit);
return [true, L1inf, L2inf, T1, T2, C];
}
function hitTestPoints(pointAt, points, hitDistance) {
const n = points.length;
const delta = Math2D.vector();
for (let i = 0; i < n; i++) {
Math2D.subtract(delta, pointAt, points[i]);
if (Math2D.L2(delta) <= hitDistance) {
return [i, delta];
}
}
return [-1];
}
function doMouseMove(pointCursor, rBtnDown) {
if (state.pointActiveIndex >= 0 && state.pointActiveMoving && rBtnDown) {
moveActivePointAndUpdate(pointCursor);
return;
}
state.pointActiveMoving = false;
const [pointHitIndex, testDelta] = hitTestPoints(
pointCursor,
state.controlPoints,
state.hitDistance,
);
state.pointActiveIndex = pointHitIndex;
canvas.style.cursor = pointHitIndex < 0 ? "auto" : "pointer";
return;
}
class ConstructionPoints {
static #vT1 = document.getElementById("value-T1");
static #vT2 = document.getElementById("value-T2");
static #vC = document.getElementById("value-C");
static print(T1, T2, C) {
function prettyPoint(P) {
return `(${P.x}, ${P.y})`;
}
if (state.haveCircle) {
this.#vT1.textContent = prettyPoint(T1);
this.#vT2.textContent = prettyPoint(T2);
this.#vC.textContent = prettyPoint(C);
} else {
this.#vT1.textContent = "undefined";
this.#vT2.textContent = "undefined";
this.#vC.textContent = "undefined";
}
}
}
function moveActivePointAndUpdate(pointCursor) {
let pointAdjusted = Math2D.point();
Math2D.subtract(pointAdjusted, pointCursor, state.mouseDelta);
if (pointAdjusted.x < 0) {
pointAdjusted.x = 0;
} else if (pointAdjusted.x >= state.canvasSize.x) {
pointAdjusted.x = state.canvasSize.x;
}
if (pointAdjusted.y < 0) {
pointAdjusted.y = 0;
} else if (pointAdjusted.y >= state.canvasSize.y) {
pointAdjusted.y = state.canvasSize.y;
}
const index = state.pointActiveIndex;
const pt = state.controlPoints[index];
let isPointChanged = false;
let indexTextInput = 1 + 2 * index;
if (pt.x !== pointAdjusted.x) {
isPointChanged = true;
pt.x = pointAdjusted.x;
textInputs[indexTextInput].elementText.textContent = pointAdjusted.x;
}
if (pt.y !== pointAdjusted.y) {
isPointChanged = true;
pt.y = pointAdjusted.y;
textInputs[indexTextInput + 1].elementText.textContent = pointAdjusted.y;
}
if (isPointChanged) {
updateResults();
}
}
function drawCanvas() {
const rPoint = 4;
const colorConstruction = "#080";
const colorDragable = "#00F";
const [P0, P1, P2] = state.controlPoints;
ctx.font = "italic 14pt sans-serif";
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 1;
if (state.haveCircle) {
ctx.strokeStyle = colorConstruction;
ctx.fillStyle = colorConstruction;
ctx.setLineDash([4, 6]);
const specialPoints = [state.C, state.T1, state.T2];
specialPoints.forEach((value) => {
ctx.beginPath();
ctx.arc(value.x, value.y, rPoint, 0, 2 * Math.PI);
ctx.fill();
});
ctx.beginPath();
ctx.moveTo(state.P0Inf.x, state.P0Inf.y);
ctx.lineTo(P1.x, P1.y);
ctx.lineTo(state.P2Inf.x, state.P2Inf.y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(state.C.x, state.C.y);
ctx.lineTo(state.T1.x, state.T1.y);
ctx.stroke();
ctx.beginPath();
ctx.arc(state.C.x, state.C.y, state.radius, 0, 2 * Math.PI);
ctx.stroke();
ctx.fillStyle = "#000";
ctx.fillText("C", state.C.x, state.C.y - 15);
ctx.fillText("T\u2081", state.T1.x, state.T1.y - 15);
ctx.fillText("T\u2082", state.T2.x, state.T2.y - 15);
ctx.fillText(
" r",
0.5 * (state.T1.x + state.C.x),
0.5 * (state.T1.y + state.C.y),
);
} else {
ctx.beginPath();
ctx.moveTo(P0.x, P0.y);
ctx.setLineDash([2, 6]);
ctx.lineTo(P1.x, P1.y);
ctx.lineTo(P2.x, P2.y);
ctx.strokeStyle = colorConstruction;
ctx.stroke();
}
state.controlPoints.forEach((value) => {
ctx.beginPath();
ctx.arc(value.x, value.y, rPoint, 0, 2 * Math.PI);
ctx.fillStyle = colorDragable;
ctx.fill();
});
ctx.fillStyle = "#000";
ctx.fillText("P\u2080", P0.x, P0.y - 15);
ctx.fillText("P\u2081", P1.x, P1.y - 15);
ctx.fillText("P\u2082", P2.x, P2.y - 15);
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(P0.x, P0.y);
ctx.setLineDash([]);
ctx.arcTo(P1.x, P1.y, P2.x, P2.y, state.radius);
ctx.strokeStyle = "#000";
ctx.stroke();
}
function addPointArrowMoves() {
[0, 1, 2].forEach((value) => addPointArrowMove(value));
}
function addPointArrowMove(indexPoint) {
const elem = document.getElementById("value-P" + indexPoint);
let indexTextInput = 2 * indexPoint + 1;
elem.addEventListener("keydown", (evt) => {
let valueNew;
let indexActive = indexTextInput;
switch (evt.code) {
case "ArrowLeft":
valueNew = textInputs[indexActive].getStateValue() - 1;
evt.preventDefault();
break;
case "ArrowUp":
valueNew = textInputs[++indexActive].getStateValue() - 1;
evt.preventDefault();
break;
case "ArrowRight":
valueNew = textInputs[indexActive].getStateValue() + 1;
evt.preventDefault();
break;
case "ArrowDown":
valueNew = textInputs[++indexActive].getStateValue() + 1;
evt.preventDefault();
break;
default:
return;
}
textInputs[indexActive].updateFull(valueNew);
});
}
const state = initDemoState(param);
const controlR = document.getElementById("radius-slider");
controlR.value = state.radius;
controlR.max = state.radiusMax;
controlR.addEventListener("input", (evt) => {
textInputs[0].elementText.textContent = controlR.value;
state.radius = controlR.value;
updateResults();
});
const textInputs = [
new TextInput(
"value-r",
"radius-slider",
state.radiusMax,
() => state.radius,
(value) => (state.radius = value),
),
new TextInput(
"value-P0x",
null,
state.canvasSize.x,
() => state.controlPoints[0].x,
(value) => (state.controlPoints[0].x = value),
),
new TextInput(
"value-P0y",
null,
state.canvasSize.y,
() => state.controlPoints[0].y,
(value) => (state.controlPoints[0].y = value),
),
new TextInput(
"value-P1x",
null,
state.canvasSize.x,
() => state.controlPoints[1].x,
(value) => (state.controlPoints[1].x = value),
),
new TextInput(
"value-P1y",
null,
state.canvasSize.y,
() => state.controlPoints[1].y,
(value) => (state.controlPoints[1].y = value),
),
new TextInput(
"value-P2x",
null,
state.canvasSize.x,
() => state.controlPoints[2].x,
(value) => (state.controlPoints[2].x = value),
),
new TextInput(
"value-P2y",
null,
state.canvasSize.y,
() => state.controlPoints[2].y,
(value) => (state.controlPoints[2].y = value),
),
];
addPointArrowMoves();
textInputs.forEach((ti) => (ti.elementText.textContent = ti.getStateValue()));
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
canvas.width = state.canvasSize.x;
canvas.height = state.canvasSize.y;
canvas.addEventListener("mousemove", (evt) =>
doMouseMove(
Math2D.point(evt.offsetX, evt.offsetY),
(evt.buttons & 1) === 1,
),
);
canvas.addEventListener("mousedown", (evt) => {
if (evt.button !== 0) {
return;
}
const [pointHitIndex, testDelta] = hitTestPoints(
Math2D.point(evt.offsetX, evt.offsetY),
state.controlPoints,
state.hitDistance,
);
if (pointHitIndex < 0) {
return;
}
state.pointActiveMoving = true;
canvas.style.cursor = "move";
state.mouseDelta = testDelta;
});
canvas.addEventListener("mouseup", (evt) => {
if (evt.button !== 0) {
return;
}
if (state.pointActiveMoving) {
state.pointActiveMoving = false;
canvas.style.cursor = "pointer";
}
});
canvas.addEventListener("mouseenter", (evt) =>
doMouseMove(
Math2D.point(evt.offsetX, evt.offsetY),
(evt.buttons & 1) === 1,
),
);
drawCanvas();
ConstructionPoints.print(state.T1, state.T2, state.C);
</script>