简介
看到文章标题,很多同学可能会疑惑,实现元素的旋转,只需要求得旋转角度,然后用CSS
中的transform:rotate(${旋转的角度}deg)
就可以实现旋转的需求,为什么要用到线性代数
的知识?
我觉得用线性代数
的知识实现元素拖拽旋转的理由如下:
- 矩阵中可以同时包含旋转、缩放、平移等信息,不需要进行冗余的计算和属性更新;
- 更加通用。
线性代数
的知识作为一种数学知识,是抽象的、通用的,很多GUI
编程技术都提供了线性代数矩阵
实现元素旋转、缩放、平移等效果,例如CSS
中transform
属性的matrix()
,Canvas
中提供的setTransform()
等API
,安卓Canvas
类提供的setMatrix()
方法。学会线性代数矩阵旋转
,就可以在各个GUI
编程技术中通吃此类需求。
拖拽旋转的原理分析
拖拽旋转本质上是绕着原点旋转,这个原点就是物体的中心。让我们用一个矩形来抽象表达这个旋转过程,以矩形中心为原点\(O\),建立\(2D\)坐标系,取一点为旋转起始点\(A\),取一点为旋转结束点\(A'\),将\(A\)、\(A'\)与\(O\)连接起来可得向量\(\overrightarrow{OA}\)、向量\(\overrightarrow{OA'}\),向量\(\overrightarrow{OA}\)和向量\(\overrightarrow{OA'}\)之间的夹角\(\theta\),可得如下图:
在JavaScript中Math.atan2()
API可以返回从\(原点(0,0)\)到\((x,y)点\)的线段与\(x轴\)正方向之间的平面角度(弧度值),所以可得求取两个向量之间的夹角弧度的代码如下:
/**
* 计算向量夹角,单位是弧度
* @param {Array.<2>} av
* @param {Array.<2>} bv
* @returns {number}
*/
function computedIncludedAngle(av, bv) {
return Math.atan2(av[1], av[0]) - Math.atan2(bv[1], bv[0]);
}
旋转矩阵
在前文线性代数在前端中的应用(一):实现鼠标滚轮缩放元素、Canvas图片和拖拽中,我们知道了缩放元素可以利用缩放矩阵
,那么旋转元素也可以利用旋转矩阵
,那么怎么推导出旋转矩阵
就成了关键。由于我们目前只关心平面维度上的旋转,所以只需要求得\(2D\)维度中的旋转矩阵
即可。
假设在\(2D\)坐标轴中有和\(X轴\)、\(Y轴\)分别平行的基向量\(p\)和基向量\(q\),它们之间的夹角为\(90^{\circ}\),将基向量\(p\)和基向量\(q\)同时旋转\(\theta度\),可以得到基向量\(p'\)和基向量\(q'\),根据\(三角函数\)即可以推导出\(p\)、\(p'\)的值。
利用基向量构造矩阵,\(2D\)旋转矩阵就如下:
$$ R(\theta)=\left[ \begin{matrix} p^{'} \\ q^{'} \\ \end{matrix} \right]=\left[ \begin{matrix} cos\theta & sin\theta \\ -sin\theta & cos\theta \end{matrix} \right] $$
转化为\(4\times4齐次矩阵\)则为:
$$ R(\theta)=\left[ \begin{matrix} p^{'} \\ q^{'} \\ r^{'}\\ w^{'} \\ \end{matrix} \right]=\left[ \begin{matrix} cos\theta & sin\theta & 0 & 0 \\ -sin\theta & cos\theta & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{matrix} \right] $$
CSS中实现矩阵变化的matrix()
函数
CSS函数matrix()
指定了一个由指定的 6 个值组成的 2D 变换矩阵。matrix(a, b, c, d, tx, ty)
是matrix3d(a, b, 0, 0, c, d, 0, 0, 0, 0, 1, 0, tx, ty, 0, 1)
的简写。
这些值表示以下函数:
matrix( scaleX(), skewY(), skewX(), scaleY(), translateX(), translateY() )
例如我们要一个div元素放大两倍,水平向右平移100px,垂直向下平移200px,可以把CSS
写成:
div {
transform:matrix(2, 0, 0, 2, 100, 200);
}
由于我们采用的是\(4\times4齐次矩阵\)进行矩阵变换计算,所以采用\(RP^{3}下的齐次坐标\)。值得注意的是,关于\(齐次坐标\)我们还可以写成下面这种形式,本文我们将采用这种形式:
$$ \left[ \begin{matrix} a & c & 0 & 0 \\ b & d & 0 & 0 \\ 0 & 0 & 1 & 0 \\ tx & ty & 0 & 1 \end{matrix} \right] $$
矩阵计算库gl-matrix
gl-matrix是一个用JavaScript
语言编写的开源矩阵计算库。我们可以利用这个库提供的矩阵之间的运算功能,来简化、加速我们的开发。为了避免降低复杂度,后文采用原生ES6
的语法,采用标签直接引用
JS
库,不引入任何前端编译工具链。
鼠标拖拽旋转Div元素
旋转效果
代码实现
index.html
矩阵旋转Div元素
index.css
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
position: relative;
margin: 0;
padding: 0;
min-height: 100vh;
}
.shape_controls {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 200px;
height: 200px;
border: 1px solid rgb(0, 0, 0);
z-index: 1;
}
.shape_controls .shape_anchor {
position: absolute;
left: 50%;
top: 0%;
transform: translate(-50%, -50%);
width: 8px;
height: 8px;
border: 1px solid rgb(6, 123, 239);
border-radius: 50%;
background-color: rgb(255, 255, 255);
z-index: 2;
}
.shape_controls .shape_rotater {
position: absolute;
left: 50%;
top: -30px;
transform: translate(-50%, 0);
width: 8px;
height: 8px;
border: 1px solid rgb(6, 123, 239);
border-radius: 50%;
background-color: rgb(255, 255, 255);
z-index: 2;
}
.shape_controls .shape_rotater:hover {
cursor: url(./rotate.gif) 16 16, auto;
}
.shape_controls .shape_rotater::after {
position: absolute;
content: "";
left: 50%;
top: calc(100% + 1px);
transform: translate(-50%, 0);
height: 18px;
width: 1px;
background-color: rgb(6, 123, 239);
}
rotate.gif
index.js
document.addEventListener("DOMContentLoaded", () => {
const $sct = document.querySelector(".shape_controls");
const $srt = document.querySelector(".shape_controls .shape_rotater");
const {left, top, width, height} = $sct.getBoundingClientRect();
// 原点坐标
const origin = [left + width / 2 , top + height / 2];
// 是否旋转中
let rotating = false;
// 旋转矩阵
let prevRotateMatrix = getElementTranformMatrix($sct);
let aVector = null;
let bVector = null;
/**
* 获取元素的变换矩阵
* @param {HTMLElement} el 元素对象
* @returns {Array.<16>}
*/
function getElementTranformMatrix(el) {
const matrix = getComputedStyle(el)
.transform
.replace("matrix(", "")
.replace(")", "")
.split(",")
.map(item => parseFloat(item.trim()));
return new Float32Array([
matrix[0], matrix[2], 0, 0,
matrix[1], matrix[3], 0, 0,
0, 0, 1, 0,
matrix[4], matrix[5], 0, 1
]);
}
/**
* 给元素设置变换矩阵
* @param {HTMLElement} el 元素对象
* @param {Array.<16>} hcm 齐次坐标4x4矩阵
*/
function setElementTranformMatrix(el, hcm) {
el.setAttribute("style", `transform: matrix(${hcm[0]} ,${hcm[4]}, ${hcm[1]}, ${hcm[5]}, ${hcm[12]}, ${hcm[13]});`);
}
/**
* 计算向量夹角,单位是弧度
* @param {Array.<2>} av
* @param {Array.<2>} bv
* @returns {number}
*/
function computedIncludedAngle(av, bv) {
return Math.atan2(av[1], av[0]) - Math.atan2(bv[1], bv[0]);
}
// 监听元素的点击事件,如果点击了旋转圆圈,开始设置起始旋转向量
$srt.addEventListener("mousedown", (e) => {
const {clientX, clientY} = e;
rotating = true;
aVector = [clientX - origin[0], clientY - origin[1]];
});
// 监听页面鼠标移动事件,如果处于旋转状态中,就计算出旋转矩阵,重新渲染
document.addEventListener("mousemove", (e) => {
// 如果不处于旋转状态,直接返回,避免不必要的无意义渲染
if (!rotating) {
return;
}
// 计算出当前坐标点与原点之间的向量
const {clientX, clientY} = e;
bVector = [clientX - origin[0], clientY - origin[1]];
// 根据2个向量计算出旋转的弧度
const angle = computedIncludedAngle(aVector, bVector);
const o = new Float32Array([
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0
]);
// 旋转矩阵
const rotateMatrix = new Float32Array([
Math.cos(angle), Math.sin(angle), 0, 0,
-Math.sin(angle), Math.cos(angle), 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]);
// 把当前渲染矩阵根据旋转矩阵,进行矩阵变换,得到新矩阵
prevRotateMatrix = glMatrix.mat4.multiply(o, prevRotateMatrix, rotateMatrix);
// 给元素设置变换矩阵,完成旋转
setElementTranformMatrix($sct, prevRotateMatrix);
aVector = bVector;
});
// 鼠标弹起后,移除旋转状态
document.addEventListener("mouseup", () => {
rotating = false;
})
});
鼠标拖拽旋转Canvas图形
旋转效果
代码实现
index.html
矩阵旋转Canvas图形
index.css
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
overflow: hidden;
}
canvas {
display: block;
}
.rotating,
.rotating div {
cursor: url(./rotate.gif) 16 16, auto !important;
}
index.js
document.addEventListener("DOMContentLoaded", () => {
const pageWidth = document.documentElement.clientWidth;
const pageHeight = document.documentElement.clientHeight;
const $app = document.querySelector("#app");
const ctx = $app.getContext("2d");
$app.width = pageWidth;
$app.height = pageHeight;
const width = 200;
const height = 200;
const cx = pageWidth / 2;
const cy = pageHeight / 2;
const x = cx - width / 2;
const y = cy - height / 2;
// 原点坐标
const origin = [x + width / 2 , y + height / 2];
// 是否旋转中
let rotating = false;
let aVector = null;
let bVector = null;
// 当前矩阵
let currentMatrix = new Float32Array([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
origin[0], origin[1], 0, 1
]);
/**
* 计算向量夹角,单位是弧度
* @param {Array.<2>} av
* @param {Array.<2>} bv
* @returns {number}
*/
function computedIncludedAngle(av, bv) {
return Math.atan2(av[1], av[0]) - Math.atan2(bv[1], bv[0]);
}
/**
* 渲染视图
* @param {MouseEvent} e 鼠标对象
*/
function render(e) {
// 清空画布内容
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.save();
// 设置线段厚度,防止在高分屏下线段发虚的问题
ctx.lineWidth = window.devicePixelRatio;
// 设置变换矩阵
ctx.setTransform(currentMatrix[0], currentMatrix[4], currentMatrix[1], currentMatrix[5], currentMatrix[12], currentMatrix[13]);
// 绘制矩形
ctx.strokeRect(-100, -100, 200, 200);
// 设置圆圈的边框颜色和填充色
ctx.fillStyle = "rgb(255, 255, 255)";
ctx.strokeStyle = "rgb(6, 123, 239)";
// 绘制矩形上边框中间的蓝色圆圈
ctx.beginPath();
ctx.arc(0, -100, 4, 0 , 2 * Math.PI);
ctx.stroke();
ctx.fill();
// 绘制可以拖拽旋转的蓝色圆圈
ctx.beginPath();
ctx.arc(0, -130, 4, 0 , 2 * Math.PI);
ctx.stroke();
ctx.fill();
// 判断是否拖拽旋转的蓝色圆圈
const {pageX, pageY} = e ? e : {pageX: -99999, pageY: -9999};
if (ctx.isPointInPath(pageX, pageY)) {
rotating = true;
}
// 绘制链接两个圆圈的直线
ctx.beginPath();
ctx.fillStyle = "transparent";
ctx.strokeStyle = "#000000";
ctx.moveTo(0, -125);
ctx.lineTo(0, -105);
ctx.stroke();
ctx.restore();
}
// 初次渲染
render();
// 监听画布的点击事件,如果点击了旋转圆圈,开始设置起始旋转向量
$app.addEventListener("mousedown", (e) => {
// 在渲染的过程中会判断是否点击了旋转圆圈,如果是,那么rotating会被设置为true
render(e);
if (!rotating) {
return;
}
const { offsetX, offsetY } = e;
aVector = [offsetX - origin[0], offsetY - origin[1]];
});
// 监听页面鼠标移动事件,如果处于旋转状态中,就计算出旋转矩阵,重新渲染
document.addEventListener("mousemove", (e) => {
// 如果不处于旋转状态,直接返回,避免不必要的无意义渲染
if (!rotating) {
return;
}
// 给画布添加旋转样式
$app.classList.add("rotating");
// 计算出当前坐标点与原点之间的向量
const { offsetX, offsetY } = e;
bVector = [offsetX - origin[0], offsetY - origin[1]];
// 根据2个向量计算出旋转的弧度
const angle = computedIncludedAngle(aVector, bVector);
// 旋转矩阵
const rotateMatrix = new Float32Array([
Math.cos(angle), Math.sin(angle), 0, 0,
-Math.sin(angle), Math.cos(angle), 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]);
// 把当前渲染矩阵根据旋转矩阵,进行矩阵变换,得到画布的新渲染矩阵
currentMatrix = glMatrix.mat4.multiply(
glMatrix.mat4.create(),
currentMatrix,
rotateMatrix,
);
render(e);
aVector = bVector;
});
// 鼠标弹起后,移除旋转状态
document.addEventListener("mouseup", () => {
rotating = false;
$app.classList.remove("rotating");
});
});