<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>
/* arcTo() 演示
* 注意:至少在 Chrome 中存在浏览器问题,涉及到光标更新。
* 参见 https://stackoverflow.com/questions/37462132/update-mouse-cursor-without-moving-mouse-with-changed-css-cursor-property
*
* 当在进入画布之前选择文本时,也会出现光标问题。在代码中增加的额外测试似乎可以最小化这些问题。
*/
"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, // 弧线半径
};
/* 2D 向量的数学运算 */
class Math2D {
/* 创建新点 */
static point(x = 0, y = 0) {
return { x: x, y: y };
}
/* 创建新向量 */
static vector(x = 0, y = 0) {
return this.point(x, y);
}
/* 减法:difference = 被减数 - 减数 */
static subtract(difference, minuend, subtrahend) {
difference.x = minuend.x - subtrahend.x;
difference.y = minuend.y - subtrahend.y;
}
/* 计算 L2 范数 */
static L2(a) {
return Math.hypot(a.x, a.y);
}
/* 点乘 */
static dot(a, b) {
return a.x * b.x + a.y * b.y;
}
/* 在由参数表示的线上找到点
* L = P0 + t * direction */
static linePointAt(P0, t, dir) {
return this.point(P0.x + t * dir.x, P0.y + t * dir.y);
}
} /* Math2D 类结束 */
/* 文本输入值允许备用输入 */
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(
"突变记录类型为 CharacterData,但节点类型为 " + target.nodeType,
);
return;
}
/* 处理通过解析输入的非数字 */
let value = parseInt(target.textContent);
value = isNaN(value) ? 0 : value;
textInput.updateFull(value);
break;
}
}
}
constructor(
idText, // 文档中元素的 id
idControl, // 元素中控件的 id(如果有的话,例如半径)
valueMax, // 允许的值范围从 0 到 maxValue,包括边界值
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": // 不允许,因为会添加 <br> 节点
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;
/* 监测输入变化。
* subtree: true 需要因为文本在内部节点中
* childList: true 需要因为 <enter> 变成了 <br> 节点 */
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);
} // TextInput 类结束
/* 基于从文本输入源接收的输入更新的函数 */
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();
}
}
} /* TextInput 类结束 */
/* 根据配置参数初始化状态 */
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; // 活动点悬停(false)或移动(true)
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,
);
}
/* 查找 `arcTo()` 用于绘制路径的几何形状 */
function findConstruction([P0, P1, P2], r, canvasSize, errorTolCenter) {
/* 查找一个半径为 r 的圆的圆心,使得圆上有一个点 T,
* 并且该点在方向 d 上有切线,圆心在与方向 dirTan 相同的切线一侧。 */
function findCenter(T, d, r, dirTan) {
/* 找到与切线正交线的方向
* 选择较大的值以避免除以 0。
* a . n = 0。设置较小的分量为 1 */
const dn =
Math.abs(d.x) < Math.abs(d.y)
? Math2D.point(1, -d.x / d.y)
: Math2D.point(-d.y / d.x, 1);
/* 如果正交向量与 dirTan 的点积小于 0,则正交向量可能指向圆心或反向。
* 如果是后者,则使其指向圆心。 */
if (Math2D.dot(dn, dirTan) < 0) {
dn.x = -dn.x;
dn.y = -dn.y;
}
/* 沿着线 Tx + t * dn 移动半径距离,即可到达圆的圆心 */
return Math2D.linePointAt(T, r / Math2D.L2(dn), dn);
}
/* 测试是否重合。注意,点将具有小整数坐标,因此检查精确的
* 相等性没有问题 */
const dir1 = Math2D.vector(P0.x - P1.x, P0.y - P1.y); // 线 1 的方向
if (dir1.x === 0 && dir1.y === 0) {
// P0 和 P1 重合
return [false];
}
const dir2 = Math2D.vector(P2.x - P1.x, P2.y - P1.y); // 线 2 的方向
if (dir2.x === 0 && dir2.y === 0) {
// P2 和 P1 重合
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);
/* 线之间的夹角 -- cos(angle) = a.b/(|a||b|)
* 使用单位向量,因此 |a| = |b| = 1 */
const dp = Math2D.dot(dir1_unit, dir2_unit);
/* 测试是否共线 */
if (Math.abs(dp) > 0.999999) {
/* 夹角接近于 0 或 180 度 */
return [false];
}
const angle = Math.acos(Math2D.dot(dir1_unit, dir2_unit));
/* 到切线点 T1 和 T2 的距离 --
* (T1, P1, C) 构成一个直角三角形 (T2, P1, C) 与上述三角形相同。
* 每个三角形的一个角是线之间角度的一半
* tan(angle/2) = r / length(P1,T1) */
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(
`程序或数值错误,` +
`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];
} /* findConstruction 函数结束 */
/* 查找数组中距离指定点最近的第一个点的索引和距离增量,
* 如果没有找到则返回索引 -1 */
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]; // 没有找到
}
/* 处理鼠标移动,适用于 mousemove 事件或 mouseentry */
function doMouseMove(pointCursor, rBtnDown) {
/* 测试是否有活动的移动。如果有,根据鼠标位置移动。右键按下标志处理以下情况:
* 鼠标在右键按下状态下离开画布,并在右键松开状态下进入画布(不移动)或按下状态下进入(移动)。
* 这也有助于处理鼠标事件不可靠传递的问题。 */
if (state.pointActiveIndex >= 0 && state.pointActiveMoving && rBtnDown) {
/* 一个点正在移动,并且继续移动 */
moveActivePointAndUpdate(pointCursor);
return;
}
/* 如果没有活动的右键移动,根据命中测试更新活动状态。
* 鼠标事件有时可能无法可靠传递,特别是在 Chrome 浏览器中,
* 因此编程必须处理这个问题 */
state.pointActiveMoving = false; // 没有移动
const [pointHitIndex, testDelta] = hitTestPoints(
pointCursor,
state.controlPoints,
state.hitDistance,
);
state.pointActiveIndex = pointHitIndex;
canvas.style.cursor = pointHitIndex < 0 ? "auto" : "pointer";
return;
} /* doMouseMove 函数结束 */
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) {
// 如果 x 或 y 改变,则更新结果
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);
/* 绘制 arcTo() 结果 */
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();
} /* rawCanvas 函数结束 */
function addPointArrowMoves() {
[0, 1, 2].forEach((value) => addPointArrowMove(value));
}
/* 允许在点标签上按箭头键移动点的 x 和 y 方向 */
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": // 左箭头--将 x 减 1
valueNew = textInputs[indexActive].getStateValue() - 1;
evt.preventDefault();
break;
case "ArrowUp": // 上箭头--将 y 减 1
valueNew = textInputs[++indexActive].getStateValue() - 1;
evt.preventDefault();
break;
case "ArrowRight": // 右箭头--将 x 加 1
valueNew = textInputs[indexActive].getStateValue() + 1;
evt.preventDefault();
break;
case "ArrowDown": // 下箭头--将 y 加 1
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>