在上一篇文章集智学园知识星空 -- 产品介绍篇中我们讲了产品新版本的特点, 简单来说就是三点:
使用二维展示方式, 展示的信息更多维, 更丰富.
使用层级化展示, 每个层级有对应的信息重点, 在展示更多信息的同时, 不产生视觉负担.
高手可便捷地自行探索学习路径, 同时也为初学者提供了推荐的学习路径.
那既然作为一个程序员, 从本篇文章开始就要剖析产品中用到的技术了. 整个产品前后端交互不多, 核心在于后端算法生成数据, 和前端酷炫的交互实现两部分.
算法过程还涉及到机密啊专利啊等等乱七八糟的事情, 不能说的太详细, 但前端部分本身就完全对外公开, 所以也谈不上技术保护. 所以我们会着重对前端的实现部分进行分享和分析.
还没有体验过的同学, 可以前往集智学园官网 https://campus.swarma.org 体验后再继续往下看.
模拟地图功能
所有的课程以分布在二维坐标系上的点的形式呈现. 那就有对视图在二维平面中上下左右移动的需求. 而且为了展示内部细节, 还需要支持缩放. 本质上就是一个地图. 所以我们首先需要实现地图的基本交互, 移动 + 缩放
之所以不使用 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);
- Windows.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 手撸的地图, 所以完全可以根据需求开发想要的功能, 虽然可能一开始如果选择了地图框架来实现功能, 前期进展肯定会比现在快, 但到了后期开发, 我相信一定是我们自己的框架更加灵活, 更有利于实现我们的想法, 而不会被技术所局限.
本篇主要介绍了地图的基础操作移动和缩放是如何实现的. 在下一篇, 我们来介绍一下更加精彩的 "窗口" 和 "路径" 实现. 敬请期待.
来源: https://juejin.im/post/5c95eadcf265da60f56116d3