一、背景:
在使用 canvas
做知识图谱的时,实体关系使用线宽为 1px 的线绘制, 用户必须点在线上, 才能正常拾取到点击的边。 边关系,有些是直线边,有些是二次贝塞尔曲线。产品提议,线不能加粗, 否则图谱展示大量数据时, 有碍美观。直线边扩展已经完成, 曲线边相对麻烦一些。 IE 浏览器不支持 canvas
判断点击事件源是否在路径上的接口 sPointInstroke
; 而对 isPointInPath
的支持,仅限于中心线封闭出来的路径, 中心线外侧的部分能通过 isPointInPath
判断是无效的,也就是说, 加宽线宽在 IE 浏览器是无法正常使用 isPointInPath
接口的。
二、方案:通过数学运算, 实现 isPointInPath
接口底层逻辑
在二次贝兹曲线的外侧绘制两条等距曲线,然后封闭路径。判断点击事件源坐标是否在该路径内,如果是,则判定该曲线被点中; 否则该曲线未被点中。以下是图示:
如图, 请看 a<0
下面的图片。
途中浅蓝色的二次曲线一共有三条, 中间的一条是图谱上已经绘制的,记为 lineA
, 上方和下方的两条曲线分别记为 lineB
和 lineC
, 可以看到 lineB
和 lineC
将 lineA
包围, 如果将 lineB
和 lineC
两端连接, 则形成了一个封闭的图形, 正好包裹住中间的曲线。
三、实现:
1. 数学几何知识提要
- 直线可以用方程来表示 :
y = ax + b
, 其中 a 为斜率, b 为常数项; - 斜率的计算方法:
a = (deltaY1 - deltaY2) / (deltaX1 / detalX2)
; - 平行的直线斜率相同, 常数项不同, 即
a
相等,b
不相等; - 垂直的直线斜率的乘积是 -1;
- 直角三角形、勾股定理、相似三角形的角和边关系以及三角函数
sin
与cos
请自行百度。
2. 注意事项
- 计算机屏幕坐标系原点
(0,0)
为屏幕的左上角, 向右为x
轴正方向, 向下为y
轴正方向。 所以在不偏移画布的前提下, 是没有负数的坐标的。 - “起点-控制点”连线形成的直线, 上图中
AH
, “终点-控制点” 连线形成的直线HK
, 若这两条直线的斜率正负号相同, 是一种情形; 这两条直线的斜率正负号不同,又是另一种情形。详见后边的代码。
3. 实现代码:
- 为了能用最小的开销实现预览代码效果, 我把代码都摘出来放到一个
html
文档中了; - 为了能更好的表达点、线的所属关系, 命名有点冗长了。倒也情有可原,原因嘛,一是现代编译工具可以帮我打包压缩,因此也无所顾忌; 二是如果不好好命名,过一段时间我自己也睁眼瞎了。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<canvas id="canvas" width="1980" height="1000"></canvas>
<script>
const run = (start, controlPoint, end) => {
const canvas = document.querySelector('#canvas')
const context = canvas.getContext('2d');
const RANGE = 10; // 拾取扩展范围
/*
求出贝塞尔曲线“起点-控制点”和“终点-控制点”的直线方程
@params controlPoint 控制点
@params controlPoint 起点
@params controlPoint 终点
*/
function getBaseEquation(controlPoint, start, end) {
const slopeOfLineControlPointToStart = (controlPoint.y - start.y) / (controlPoint.x - start.x) // 控制点-起点斜率
const slopeOfLineControlPointToEnd = (controlPoint.y - end.y) / (controlPoint.x - end.x) // 控制点-终点斜率
const absoluteTermOfLineToStart = start.y - slopeOfLineControlPointToStart * start.x // 控制点-起点直线方程常数项
const absoluteTermOfLineToEnd = end.y - slopeOfLineControlPointToEnd * end.x // 控制点-终点直线方程常数项
const lineControlPointToStart = x => slopeOfLineControlPointToStart * x + absoluteTermOfLineToStart // 控制点-起点直线方程
const lineControlPointToEnd = x => slopeOfLineControlPointToEnd * x + absoluteTermOfLineToEnd // 控制点-终点直线方程
return {
lineControlPointToStart,
lineControlPointToEnd,
slopeOfLineControlPointToStart,
slopeOfLineControlPointToEnd,
absoluteTermOfLineToStart,
absoluteTermOfLineToEnd
}
}
const { lineControlPointToStart,
lineControlPointToEnd,
slopeOfLineControlPointToStart,
slopeOfLineControlPointToEnd,
absoluteTermOfLineToStart,
absoluteTermOfLineToEnd } = getBaseEquation(controlPoint, start, end)
/*
根据贝塞尔曲线“起点-控制点”和“终点-控制点”的直线方程,求出扩展后的上下四条平行线的方程
*
*/
function getShiftedEquation(controlPoint, start, end, range) {
const hypotenuseOfStart = Math.sqrt(Math.pow(controlPoint.x - start.x, 2) + Math.pow(controlPoint.y - start.y, 2)) // 控制点-起点弦长
const RANGE_OF_START = RANGE * hypotenuseOfStart / Math.abs(controlPoint.x - start.x) // 与 “控制点-起点”直线平行的两条直线的 y 轴偏移量
const hypotenuseOfEnd = Math.sqrt(Math.pow(controlPoint.x - end.x, 2) + Math.pow(controlPoint.y - end.y, 2)) // 控制点-终点弦长
const RANGE_OF_END = RANGE * hypotenuseOfEnd / Math.abs(controlPoint.x - end.x)// 与 “控制点-终点”直线平行的两条直线的 y 轴偏移量
const lineLeftAbove = x => (lineControlPointToStart(x) + RANGE_OF_START) // 起点-控制点向上偏移方程
const lineLeftBellow = x => (lineControlPointToStart(x) - RANGE_OF_START) // 起点-控制点向下偏移方程
const lineRightAbove = x => (lineControlPointToEnd(x) + RANGE_OF_END) // 终点-控制点向上偏移方程
const lineRightBellow = x => (lineControlPointToEnd(x) - RANGE_OF_END) // 终点-控制点向下偏移方程
return {
RANGE_OF_START,
RANGE_OF_END,
lineLeftAbove,
lineLeftBellow,
lineRightAbove,
lineRightBellow
}
}
const {
RANGE_OF_START,
RANGE_OF_END,
lineLeftAbove,
lineLeftBellow,
lineRightAbove,
lineRightBellow
} = getShiftedEquation(controlPoint, start, end, RANGE)
/***
* 求出偏移后的控制点, 偏移直线的交点即为偏移后的控制点
*/
function getShiftedControlPoint() {
let controlPonitAboveX = (absoluteTermOfLineToEnd + RANGE_OF_END - absoluteTermOfLineToStart - RANGE_OF_START) / (slopeOfLineControlPointToStart - slopeOfLineControlPointToEnd)
if (slopeOfLineControlPointToStart * slopeOfLineControlPointToEnd < 0) {
controlPonitAboveX = (absoluteTermOfLineToEnd - RANGE_OF_END - absoluteTermOfLineToStart - RANGE_OF_START) / (slopeOfLineControlPointToStart - slopeOfLineControlPointToEnd)
}
const controlPointAbove = {
x: controlPonitAboveX,
y: lineLeftAbove(controlPonitAboveX)
}
let controlPonitBelloweX = (absoluteTermOfLineToEnd - RANGE_OF_END - absoluteTermOfLineToStart + RANGE_OF_START) / (slopeOfLineControlPointToStart - slopeOfLineControlPointToEnd)
if (slopeOfLineControlPointToStart * slopeOfLineControlPointToEnd < 0) {
controlPonitBelloweX = (absoluteTermOfLineToEnd + RANGE_OF_END - absoluteTermOfLineToStart + RANGE_OF_START) / (slopeOfLineControlPointToStart - slopeOfLineControlPointToEnd)
}
const controlPointBellow = {
x: controlPonitBelloweX,
// slopeOfLineControlPointToStart * slopeOfLineControlPointToEnd < 0 时, 线要变
y: lineLeftBellow(controlPonitBelloweX)
}
return {
controlPointAbove,
controlPointBellow
}
}
const { controlPointAbove, controlPointBellow } = getShiftedControlPoint()
/**
* 求出经过左、右侧端点的垂线方程
* 求出偏移后的两条贝塞尔曲线的起点和终点坐标
*/
function getShiftedStartAndEndPoints() {
const absoluteTermOfPerpendicularLineControlPointToStart = start.y + 1 / slopeOfLineControlPointToStart * start.x // “控制点-起点”偏移方程的常数项
const absoluteTermOfPerpendicularLineControlPointToEnd = end.y + 1 / slopeOfLineControlPointToEnd * end.x // “控制点-终点”偏移方程的常数项
const perpendicularLineControlPointToStart = x => -(1 / slopeOfLineControlPointToStart) * x + absoluteTermOfPerpendicularLineControlPointToStart // 垂线方程
const perpendicularLineControlPointToEnd = x => -(1 / slopeOfLineControlPointToEnd) * x + absoluteTermOfPerpendicularLineControlPointToEnd // 垂线方程
// 5. 求出4个垂足的坐标
// 5.1 上边曲线起点
// y = slopeOfLineControlPointToStart * x + absoluteTermOfLineToStart + RANGE_OF_START
// y = -(1/slopeOfLineControlPointToStart) * x + absoluteTermOfPerpendicularLineControlPointToStart
// 0 = (slopeOfLineControlPointToStart + 1/slopeOfLineControlPointToStart) * x + absoluteTermOfLineToStart + RANGE_OF_START -absoluteTermOfPerpendicularLineControlPointToStart
const leftAboveStartX = (absoluteTermOfPerpendicularLineControlPointToStart - absoluteTermOfLineToStart - RANGE_OF_START) / (slopeOfLineControlPointToStart + 1 / slopeOfLineControlPointToStart)
const leftAboveStart = {
x: leftAboveStartX,
y: lineLeftAbove(leftAboveStartX)
}
// 5.2 上边曲线终点
const rightAboveEndX = (absoluteTermOfPerpendicularLineControlPointToEnd - absoluteTermOfLineToEnd - RANGE_OF_END) / (slopeOfLineControlPointToEnd + 1 / slopeOfLineControlPointToEnd)
const rightAboveEnd = {
x: rightAboveEndX,
y: lineRightAbove(rightAboveEndX)
}
// 5.3 下边曲线起点
// y = slopeOfLineControlPointToStart * x + absoluteTermOfLineToStart - RANGE_OF_START
// y = -(1/slopeOfLineControlPointToStart) * x + absoluteTermOfPerpendicularLineControlPointToStart
// 0 = (slopeOfLineControlPointToStart + 1/slopeOfLineControlPointToStart) * x + absoluteTermOfLineToStart - RANGE_OF_START - absoluteTermOfPerpendicularLineControlPointToStart
const leftBellowStartX = (absoluteTermOfPerpendicularLineControlPointToStart + RANGE_OF_START - absoluteTermOfLineToStart) / (slopeOfLineControlPointToStart + 1 / slopeOfLineControlPointToStart)
const leftBellowStart = {
x: leftBellowStartX,
y: lineLeftBellow(leftBellowStartX)
}
// 5.4 下边曲线终点
const rightBellowX = (absoluteTermOfPerpendicularLineControlPointToEnd + RANGE_OF_END - absoluteTermOfLineToEnd) / (slopeOfLineControlPointToEnd + 1 / slopeOfLineControlPointToEnd)
const rightBellowEnd = {
x: rightBellowX,
y: lineRightBellow(rightBellowX)
}
return {
leftAboveStart,
rightAboveEnd,
leftBellowStart,
rightBellowEnd,
}
}
const { leftAboveStart,
rightAboveEnd,
leftBellowStart,
rightBellowEnd
} = getShiftedStartAndEndPoints()
// 6. 画圆环
if (slopeOfLineControlPointToStart * slopeOfLineControlPointToEnd < 0) {
context.save();
context.beginPath();
context.moveTo(leftAboveStart.x, leftAboveStart.y);
context.quadraticCurveTo(controlPointAbove.x, controlPointAbove.y, rightBellowEnd.x, rightBellowEnd.y);
context.lineTo(rightAboveEnd.x, rightAboveEnd.y);
context.quadraticCurveTo(controlPointBellow.x, controlPointBellow.y, leftBellowStart.x, leftBellowStart.y);
context.lineTo(leftAboveStart.x, leftAboveStart.y);
context.stroke();
context.restore();
} else {
// 6. 画圆环
context.save();
context.moveTo(leftAboveStart.x, leftAboveStart.y);
context.quadraticCurveTo(controlPointAbove.x, controlPointAbove.y, rightAboveEnd.x, rightAboveEnd.y);
context.lineTo(rightBellowEnd.x, rightBellowEnd.y);
context.quadraticCurveTo(controlPointBellow.x, controlPointBellow.y, leftBellowStart.x, leftBellowStart.y);
context.lineTo(leftAboveStart.x, leftAboveStart.y);
context.stroke();
context.restore();
}
// 1. 以下是辅助作图, 对实际作图无影响, 只是便于理解
// 2. 以下是辅助作图, 对实际作图无影响, 只是便于理解
// 3. 以下是辅助作图, 对实际作图无影响, 只是便于理解
// 画中心贝塞尔曲线
context.save();
context.beginPath();
context.beginPath();
context.moveTo(start.x, start.y);
context.quadraticCurveTo(controlPoint.x, controlPoint.y, end.x, end.y);
context.stroke();
context.restore();
const drawCircle = (x, y, color) => {
context.save()
context.beginPath();
context.moveTo(x, y);
context.strokeStyle = color;
context.fillStyle = color;
context.arc(x, y, 5, 0, Math.PI * 2);
context.fill();
context.restore();
}
drawCircle(controlPoint.x, controlPoint.y, 'red')
drawCircle(controlPointAbove.x, controlPointAbove.y, 'green')
drawCircle(controlPointBellow.x, controlPointBellow.y, 'blue')
drawCircle(leftAboveStart.x, leftAboveStart.y, 'black')
drawCircle(leftBellowStart.x, leftBellowStart.y, 'black')
const drawLines = (x1, y1, x2, y2, x3, y3, color) => {
context.save();
context.beginPath();
context.strokeStyle = color || "#000";
context.moveTo(x1, y1);
context.lineTo(x2, y2);
context.lineTo(x3, y3);
context.stroke();
context.restore();
}
// if (slopeOfLineControlPointToStart * slopeOfLineControlPointToEnd < 0) {
// // 高线起点---高线控制点的线---高线终点
// drawLines(leftAboveStart.x, leftAboveStart.y, controlPointAbove.x, controlPointAbove.y, rightBellowEnd.x, rightBellowEnd.y)
// drawLines(leftBellowStart.x, leftBellowStart.y, controlPointBellow.x, controlPointBellow.y, rightAboveEnd.x, rightAboveEnd.y)
// } else {
// // 高线起点---高线控制点的线---高线终点
// drawLines(leftAboveStart.x, leftAboveStart.y, controlPointAbove.x, controlPointAbove.y, rightAboveEnd.x, rightAboveEnd.y)
// drawLines(leftBellowStart.x, leftBellowStart.y, controlPointBellow.x, controlPointBellow.y, rightBellowEnd.x, rightBellowEnd.y)
// }
}
const line1 = run(
{ x: 321, y: 743 },
{ x: 345, y: 632 },
{ x: 324, y: 126 }
)
const line2 = run(
{ x: 621, y: 743 },
{ x: 587, y: 632 },
{ x: 612, y: 126 }
)
const line3 = run(
{ x: 1432, y: 200 },
{ x: 1342, y: 600 },
{ x: 800, y: 800 }
)
const line4 = run(
{ x: 900, y: 200 },
{ x: 800, y: 600 },
{ x: 700, y: 800 }
)
</script>
</body>
</html>
四、效果预览:
如下图, 一共有四条曲线, 每条曲线都被两条曲线包围, 并且两端封闭。我们只需要判断点击事件源坐标是否在封闭图形内部, 即可判定封闭路径内部的曲线是否被点中, 这样就可以实现在不加宽线宽的前提下,扩展点击范围的目的了。几点说明:
- 黑色的点是扩展曲线的起点;
- 红色点点是中间曲线的控制点;
- 蓝色的点和绿色的点是两条扩展曲线的控制点。