集智学园知识星空——前端技术实现分析(一)

image.png
在上一篇文章 集智学园知识星空——产品介绍篇中我们讲了产品新版本的特点,简单来说就是三点:

  1. 使用二维展示方式,展示的信息更多维,更丰富。
  2. 使用层级化展示,每个层级有对应的信息重点,在展示更多信息的同时,不产生视觉负担。
  3. 高手可便捷地自行探索学习路径,同时也为初学者提供了推荐的学习路径。

那既然作为一个程序员,从本篇文章开始就要剖析产品中用到的技术了。整个产品前后端交互不多,核心在于后端算法生成数据,和前端酷炫的交互实现两部分。

算法过程还涉及到机密啊专利啊等等乱七八糟的事情,不能说的太详细,但前端部分本身就完全对外公开,所以也谈不上技术保护。所以我们会着重对前端的实现部分进行分享和分析。

还没有体验过的同学,可以前往集智学园官网体验后再继续往下看。

模拟地图功能

所有的课程以分布在二维坐标系上的点的形式呈现。那就有对视图在二维平面中上下左右移动的需求。而且为了展示内部细节,还需要支持缩放。本质上就是一个地图。所以我们首先需要实现地图的基本交互,移动 + 缩放

之所以不使用google或者百度地图这类现有的地图框架,一是因为我们其实只需要地图的部分交互,其实没必要引入庞大的地图库;二是我们希望能更灵活地对这个"地图"进行自定义开发,后续可能会在现有基础上增加更多的交互或者元素。

另外地图组件本质是图片的分片加载,所以难免在移动和缩放的时候出现中间加载时刻。所以在经过了一段时间的尝试之后我们放弃了对地图库的引入。

1. 核心绘图

整个视图的组成主要元素是那些课程点,这些点都是绘制在一个canvas上 核心绘图函数很简单

drawPoint (point) {
  ctx.arc(point.x, point.y, point.r, 0, 2 * Math.PI);
}
复制代码

点位的坐标生成是另外的技术话题,大致流程是将课程信息(包括资料,文本,标签等)提取出来转化为高维课程特征矩阵,再通过聚类和降维技术映射成二维坐标。具体实现将另开篇幅。本文针对前端实现方式,不对此展开讨论。

2. 引入监听事件
  1. 移动功能用到了
  • mousedown, // 鼠标移动
  • mousestart // 鼠标点下
  • mouseup // 鼠标抬起
  1. 缩放功能用到了
  • 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事件,这就需要对单纯的“鼠标移动”,和按下后的“拖拽”做一个区分,所以需要mousedownmouseup事件的配合,来判断当前是否为拖拽状态。

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. 缩放功能

有很多操作会触发缩放:

  1. 双击地图
  2. 鼠标滚动
  3. 笔记本触控板

双击触发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)

1
第二步,放大整个坐标系至相应倍数,这里为2倍, 得到圆(2, 2, r = 2)
2

第三步,把坐标系移回原来的位置,让p回到初始点,得到圆(3, 3, r = 2)

3

从这道题中可以看出,要把一个点以某一中心进行缩放,还需要借助平移的方法,所以讲了这么一堆,可以得出缩放函数应该这么写

// 缩放函数
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手撸的地图,所以完全可以根据需求开发想要的功能,虽然可能一开始如果选择了地图框架来实现功能,前期进展肯定会比现在快,但到了后期开发,我相信一定是我们自己的框架更加灵活,更有利于实现我们的想法,而不会被技术所局限。

本篇主要介绍了地图的基础操作移动缩放是如何实现的。 在下一篇,我们来介绍一下更加精彩的“窗口”和“路径”实现。 敬请期待。

猜你喜欢

转载自juejin.im/post/5c95eadcf265da60f56116d3