<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>