在上一篇文章开创史无前例的在线学习方式——“集智知识星空”产品技术解剖(一)中我们讲了产品新版本的特点,简单来说就是三点:
- 使用二维展示方式,展示的信息更多维,更丰富。
- 使用层级化展示,每个层级有对应的信息重点,在展示更多信息的同时,不产生视觉负担。
- 高手可便捷地自行探索学习路径,同时也为初学者提供了推荐的学习路径。
那既然作为一个程序员,从本篇文章开始就要剖析产品中用到的技术了。整个产品前后端交互不多,核心在于后端算法生成数据,和前端酷炫的交互实现两部分。
算法过程还涉及到机密啊专利啊等等乱七八糟的事情,不能说的太详细,但前端部分本身就完全对外公开,所以也谈不上技术保护。所以我们会着重对前端的实现部分进行分享和分析。
还没有体验过的同学,可以前往集智学园官网体验后再继续往下看。
模拟地图功能
所有的课程以分布在二维坐标系上的点的形式呈现。那就有对视图在二维平面中上下左右移动的需求。而且为了展示内部细节,还需要支持缩放。本质上就是一个地图。所以我们首先需要实现地图的基本交互,移动 + 缩放
之所以不使用google或者百度地图这类现有的地图框架,一是因为我们其实只需要地图的部分交互,其实没必要引入庞大的地图库;二是我们希望能更灵活地对这个"地图"进行自定义开发,后续可能会在现有基础上增加更多的交互或者元素。
另外地图组件本质是图片的分片加载,所以难免在移动和缩放的时候出现中间加载时刻。所以在经过了一段时间的尝试之后我们放弃了对地图库的引入。
1. 核心绘图
整个视图的组成主要元素是那些课程点,这些点都是绘制在一个canvas上
核心绘图函数很简单
drawPoint (point) {
ctx.arc(point.x, point.y, point.r, 0, 2 * Math.PI);
}
点位的坐标生成是另外的技术话题,大致流程是将课程信息(包括资料,文本,标签等)提取出来转化为高维课程特征矩阵,再通过聚类和降维技术映射成二维坐标。具体实现将另开篇幅。本文针对前端实现方式,不对此展开讨论。
2. 引入监听事件
- 移动功能用到了
-
mousedown
, // 鼠标移动 -
mousestart
// 鼠标点下 -
mouseup
// 鼠标抬起
- 缩放功能用到了
-
dblclick
// 鼠标双击 -
mousewheel
// 鼠标滚轮 -
DOMMouseScroll
// firfox的鼠标滚轮
设置事件函数,将所有事件绑定在视图的canvas上
//设置事件
setHandler(dom) {
//鼠标双击
dom.addEventListener( 'dblclick',e => {
onDocumenDblClick(e, this, false);
}, { passive: true });
//鼠标按下
dom.addEventListener('mousedown', e => {
moveDown(e, this, false);
}, { passive: true });
//鼠标移动
dom.addEventListener('mousemove', e => {
moveMouse(e, this, point);
});
//鼠标抬起
dom.addEventListener( 'mouseup', e => {
moveUP(e, this);
}, { passive: true });
//鼠标滚轮
dom.onmousewheel = e => { e.stopPropagation();
mouseScroll(e, this, false);
};
// 鼠标滚轮事件firfox
dom.addEventListener('DOMMouseScroll', e => {
mouseScroll(e, this, false);
});
},
设置好事件后,就是地图功能实现的核心了。移动 + 缩放
3. 拖拽移动功能
移动主要监听mousemove
事件,这就需要对单纯的“鼠标移动”,和按下后的“拖拽”做一个区分,所以需要mousedown
和mouseup
事件的配合,来判断当前是否为拖拽状态。
let dragFlag = false; // 拖拽标识
/*鼠标点下事件 @param {*} e event */
moveDown (e) => {
dragFlag = true; // 鼠标被按下,准备拖拽
}
/*鼠标抬起事件 @param {*} e event */
moveUP (e) => {
dragFlag = false; //结束拖拽标识
},
/** 拖拽事件 @param {*} e event */
moveMouse (e) => {
if (dragFlag) {
...
transform(x, y); // x, y为地图移动的距离
}
},
至于拖拽的距离,则取决于上一时刻
的位置,和当前位置
的差值。所以在移动的过程中,需要去记录上一时刻的位置。初始位置,为鼠标按下的位置
let lastPointPos = [];
// 鼠标按下
moveDown (e) => {
dragFlag = true; // 鼠标被按下,准备拖拽
lastPointPos = [e.clientX, e.clientY]
}
// 鼠标拖拽
moveMouse (e) => {
if (dragFlag) {
let x = e.clientX - lastPoint[0];
let y = e.clientY - lastPoint[1];
lastPoint = [e.clientX, e.clientY];
transform(x, y);
}
}
这样一来, transform
函数就能专注实现移动点位
// 移动点位函数
transform (x, y) => {
this.x = this.x + x;
this.y = this.y + y
drawPoint();
})
}
到这里,拖拽移动地图的功能基本完成
接下去,我们来说一说稍微复杂的缩放操作。
4. 缩放功能
有很多操作会触发缩放:
- 双击地图
- 鼠标滚动
- 笔记本触控板
双击触发dbclick
事件
鼠标滚动和触控板的行为基本一致,都是触发鼠标滚轮mousewheel
(firfox触发的是DOMMouseScroll
事件)
// 双击事件
onDocumenDblClick (e) => {
...
let flag = 'large';
scale(x, y, flag) // scale为缩放函数,传入缩放中心,和放大还是缩小标志
}
// 滚动事件
mouseScroll (e) => {
...
scale(x, y, flag) // scale为缩放函数,传入缩放中心,和放大还是缩小标志
}
因为每次双击的缩放尺度,和每次滚轮的缩放尺度,显然是不一样的。所以两个行为的缩放倍数。肯定不一样。我们可以设置,每触发一次双击事件,就相当于触发了n次的scale(n为一个自定义的参数), 即
onDocumenDblClick (e) => {
...
let flag = 'large';
let count = 0;
let time = setInterval(() => {
if (count <= n) {
scale(x, y, flag) // scale为缩放函数,传入缩放中心,和放大还是缩小标志
} else {
clearInterval(time)
}
}, 100)
}
这么写当然可以实现功能,但是一点都不优雅,而且使用setInterval做动画对浏览器来说并不是一个最佳的渲染方案,点位多的时候容易有失帧现象。这里钻一下细节,使用requestAnimationFrame
改写下。
let scaleStartTime = 0; // 开始放大的起始时间
// 双击事件
onDocumenDblClick (e) => {
...
let flag = 'large';
scaleStartTime = performance.now();
scaleOnceAnimation(e, time, flag); // time是自定义参数,自行设置动画要运行的时间。
}
// 循环动画
scaleOnceAnimation (e, time, flag) => {
// 使用当前时间和起始时间做对比,每次循环都判断是否已经达到设置的动画运行时间。
if (performance.now() - scaleStartTime > time) {
scaleStartTime = 0;
return;
}
scale(x, y, flag);
window.requestAnimationFrame(() => {
scaleOnceAnimation(e, time, flag);
});
}
最后就是scale函数的实现。在直接写代码之前,我们先来做个简单的数学题。
以p(1, 1)为中心,把圆(2, 2, r = 1)放大为原来的两倍,求圆放大后的坐标和半径
第一步,移动整个坐标,直至p位于(0, 0)点,此时圆坐标为(1, 1, r = 1)
第二步,放大整个坐标系至相应倍数,这里为2倍, 得到圆(2, 2, r = 2)
第三步,把坐标系移回原来的位置,让p回到初始点,得到圆(3, 3, r = 2)
从这道题中可以看出,要把一个点以某一中心进行缩放,还需要借助平移的方法,所以讲了这么一堆,可以得出缩放函数应该这么写
// 缩放函数
scale (x, y, flag) => {
let scale = flag === 'large' ? 110 / 100 ? 100 / 110; // 缩放比例
transform(-x, -y);
this.x = this.x * scale;
this.y = this.y * scale;
transform(x, y);
this.drawPoint()
})
}
到此为止,缩放的功能就也已经基本实现。一个模拟地图行为的产品也已经实现了最核心的功能。
在此基础上,我们还可以模拟其他衍伸功能,比如:
-
viewPort (pointArray)
:把传入的点放置于视图中合适的位置; -
panTo (x, y)
:把视图移动到某个位置,并以传入的坐标为视图中心(或任何一个你想要的位置点) -
openWindow (point)
:打开点位的信息窗口
除了模拟地图API的基本功能以外,还能根据需求开发自己的地图新功能 -
scaleToValue(point, value)
:对某个点移动到视图中心,并放大到指定大小 -
scaleToRange(range)
:缩放地图,直到满足传入到视图范围内
....
由于是完全canvas手撸的地图,所以完全可以根据需求开发想要的功能,虽然可能一开始如果选择了地图框架来实现功能,前期进展肯定会比现在快,但到了后期开发,我相信一定是我们自己的框架更加灵活,更有利于实现我们的想法,而不会被技术所局限。
本篇主要介绍了地图的基础操作移动
和缩放
是如何实现的。
在下一篇,我们来介绍一下更加精彩的“窗口”和“路径”实现。
敬请期待。