当前位置:   article > 正文

前端canvas项目实战——在线图文编辑器(十):小地图MiniMap(上)

前端canvas项目实战——在线图文编辑器(十):小地图MiniMap(上)

前言

上一篇博文中,我们引入了「逻辑画布」的概念,让整个工具的页面看起来 “专业” 了很多。这也为后续的很多实用的功能打下了基础,例如本篇博文要讲到的小地图MiniMap

如果你使用过市面上的一些图文编辑器,或者玩过一些游戏,就很容易理解「小地图」的作用。我们在屏幕中能看到的部分——前文中已经介绍过——叫做「视口」,视口之外的部分都看不到。小地图的作用是将全局视图按一定比例缩小,让我们能大体上总览全局的内容。

这篇博文是《前端canvas项目实战——在线图文编辑器》付费专栏系列博文的第十篇——小地图MiniMap(上),主要的内容有:

  1. 小地图的作用及实现前的设计。
  2. 实现为小地图添加背景图、滑动窗口和遮罩。

如有需要,你可以:

  • 点击这里,阅读序文《前端canvas项目实战——在线图文编辑器:序》
  • 点击这里,返回上一篇《前端canvas项目实战——在线图文编辑器(九):逻辑画布》
  • 点击这里,前往下一篇《前端canvas项目实战——在线图文编辑器(十一):小地图MiniMap(下)》

一、 效果展示

  • 动手体验
    CodeSandbox会自动对代码进行编译,并提供地址以供体验代码效果
    由于CSDN的链接跳转有问题,会导致页面无法工作,请复制以下链接在浏览器打开:
    https://zss88t.csb.app/

  • 动态效果
    这一节之后,我们就可以拖动小地图中的滑动窗口来改变画布的视口了。


二、 实现步骤

0. 行动前的思考

动手实现之前,让我们先思考几个问题:

  • 小地图使用什么方法实现?
    经过调研,市面上的图文编辑器基本上都是通过堆叠div标签实现小地图的。但是理性的思考告诉我们,这种方式实现的小地图可维护性很低,且实现起来很复杂。因此我决定再用一个canvas来实现我们的小地图, 就好像「主画布」的一个缩小版。

  • 小地图应该设计成什么形状,多大?
    具体的来说,这个问题就是说我们的小地图,「长」和「宽」应该设置为多少,怎样的比例。经过思考,我认为小地图的长宽比应该和画布的「视口」一样。其中长和宽应该是视口的1/10。这里引入一个变量canvasMiniMapRatio=10,意为画布的长宽和小地图的长宽比为10:1,这个变量会在下文中多次用到。

  • 小地图中应该有哪些元素?

    • 背景图: 显示画布中的各个对象的缩小视图。
    • 滑动窗口: 在画布被放大后,允许用户拖动滑动窗口改变画布的视口,看到画布的每个角落。
    • 遮罩: 小地图上,滑动窗口以外的区域,要有一层半透明的遮罩,用于突出滑动窗口所框选的区域。遮罩的区域随着滑动窗口的大小、位置变化而变化。

1. 为小地图更新「背景图」

先看代码:

	/**
	 * (根据画布中的对象变化)更新小地图的背景图
	 * @param canvas 画布实例
	 * @param miniMap 小地图实例
	 * @param canvasMiniMapRatio 画布和小地图尺寸的比例(默认为10)
	 */
	const updateMiniMapBackground = ({canvas, miniMap, canvasMiniMapRatio}) => {
	    // 1. 记录画布当前的viewportTransform数组
	    const viewportTransform = canvas.viewportTransform;
	
	    // 2. 缩放画布到适配窗口大小并居中
	    zoomAndCenterCanvas(canvas);
	
	    // 3. 获取当前画布截图
	    const canvasElement = canvas.toCanvasElement(1 / canvasMiniMapRatio);
	    const backgroundImage = new fabric.Image(canvasElement);
	
	    // 4. 恢复画布的viewportTransform为记录的值
	    canvas.setViewportTransform(viewportTransform);
	
	    // 5. 将得到的背景图赋值给小地图
	    miniMap.setBackgroundImage(backgroundImage);
	    miniMap.renderAll();
	};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

代码的逻辑很清晰,共分为5个步骤:

1) 记录画布当前的viewportTransform数组: 由于第2步会改变这个值,且后续的步骤还需要用到它来把画布恢复到当前位置,所以先用一个变量做记录。
2) 缩放画布到适配窗口大小并居中: 由于需要获取画布截图时,其视口可能已经不在正中央,且经过了缩放,大小发生了变化。所以在截图之前要先让画布缩放回适配窗口的大小且居中。zoomAndCenterCanvas方法的代码逻辑我在上篇博文中有详细的讲解,如需回顾,点击前往
3) 获取当前画布的截图: 先获取1 / 10大小的画布DOM对象,然后通过new fabirc.Image来创建截图。
4) 恢复画布的viewportTransform为记录的值: 把画布的缩放情况和视口恢复回获取截图前的状态。
5) 将得到的背景图赋值给小地图: 别忘了,miniMap也是一个fabric.Canvas的实例,所以我们可以通过setBackgroundImage为miniMap设置背景图。

2. 为小地图更新「滑动窗口」

滑动窗口小地图上的一个很实用的交互工具,用户可以通过拖动滑动窗口来改变画布的视口。当画布被放大时,滑动窗口所包围的区域就是当前视口所展现的,画布中我们正在看的部分;滑动窗口以外的部分,不在视口中,移动视口之后才可以看到。

代码如下:

	/**
	 * 更新小地图的滑动窗口
	 * @param canvas 画布实例
	 * @param miniMap 小地图实例
	 * @param canvasMiniMapRatio 画布和小地图尺寸的比例(默认为10)
	 */
	const updateMiniMapSlideWindow = ({canvas, miniMap, canvasMiniMapRatio}) => {
	    ...
	
	    // 1. 清除小地图中的所有对象(滑动窗口 + 4个遮罩)
	    const objects = miniMap.getObjects();
	    for (let i = 0; i < objects.length; i++) {
	        miniMap.remove(objects[i]);
	    }
	
	    // 2. 获取新的滑动窗口
	    let newSlideWindow = getNewSlideWindow({canvas, miniMap, canvasMiniMapRatio});
	
	    // 3. 添加新的滑动窗口到小地图中
	    miniMap.add(newSlideWindow);
	    miniMap.setActiveObject(newSlideWindow);
	    miniMap.renderAll();
	};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

这里的代码看起来很简单,注释很清楚,就不再赘述了。我们把getNewSlideWindow方法展开讲解:

	/**
	 * (根据画布的视口)获取小地图中新的滑动窗口实例
	 * @param canvas 画布实例
	 * @param miniMap 小地图实例
	 * @param canvasMiniMapRatio 画布宽高 : 小地图宽高(默认=10)
	 * @returns {fabric.Rect} 新的滑动窗口实例
	 */
	const getNewSlideWindow = ({canvas, miniMap, canvasMiniMapRatio}) => {
	    let canvasZoomRatio = canvas.getZoom() / calcZoomValueToFitWindow();
	
	    // 1. 获取新的滑动窗口宽高
	    const dimensions = _getNewSlideWindowDimensions({miniMap, canvasZoomRatio});
	
	    // 2. 获取新的滑动窗口位置
	    const positions = _getNewSlideWindowPositions({canvas, miniMap, canvasMiniMapRatio, canvasZoomRatio});
	
	    // 3. 实例化一个fabric.Rect作为滑动窗口,并返回
	    return new fabric.Rect({
	        fill: 'transparent',
	        stroke: "dodgerblue",
	        strokewidth: 1,
	        hasControls: false,
	        hasBorders: false,
	        ...dimensions,
	        ...positions
	    });
	};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

可以看到,「滑动窗口」实际上是一个fabric.Rect的实例,通过hasControls: falsehasBorders: false设置了它没有选择框、也没有控制点。

重点在于1/2两步,如何计算滑动窗口的宽高位置, 这里我们再展开一层,看看计算这些数值的代码逻辑。

2.1 获取新的滑动窗口「宽高」

	/**
	 * 计算得到新的滑动窗口宽高
	 * @param miniMap 小地图实例
	 * @param canvasZoomRatio 画布当前缩放值 : 画布适配窗口大小缩放值
	 * @returns {{width: number, height: number}} 新的滑动窗口宽高
	 * @private
	 */
	const _getNewSlideWindowDimensions = ({miniMap, canvasZoomRatio}) => {
	    if (canvasZoomRatio <= 1) {
	        canvasZoomRatio = 1;
	    }
	
	    return {
	        width: miniMap.width / canvasZoomRatio - 1,
	        height: miniMap.height / canvasZoomRatio - 1
	    };
	};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

这段代码包含以下两个部分:

  • 1) 滑动窗口的宽高和画布的缩放值zoom成反比,画布被放大得越大,滑动窗口越小,其中的canvasZoomRatio=画布当前缩放值 : 画布适配窗口大小缩放值,即上文getNewSlideWindow方法中的canvasZoomRatio = canvas.getZoom() / calcZoomValueToFitWindow()
  • 2) 滑动窗口的大小有上限,即最大只能和小地图1:1,也就有了代码前3行限制canvasZoomRatio最小为1

2.2 获取新的滑动窗口「位置」

	/**
	 * 计算得到新的滑动窗口位置
	 * @param canvas 画布实例
	 * @param miniMap 小地图实例
	 * @param canvasMiniMapRatio 画布宽高 : 小地图宽高(默认=10)
	 * @param canvasZoomRatio 画布当前缩放值 : 画布适配窗口大小缩放值
	 * @returns {{top: number, left: number}} 新的滑动窗口位置
	 * @private
	 */
	const _getNewSlideWindowPositions = ({canvas, miniMap, canvasMiniMapRatio, canvasZoomRatio}) => {
	    // 计算逻辑画布中心点在视口坐标系中的位置
	    const viewportTransform = canvas.viewportTransform;
	    const {logicCanvas} = store.getState();
	
	    const offsetX = ((logicCanvas.width / 2) * viewportTransform[0] + viewportTransform[4]) / canvasMiniMapRatio / canvasZoomRatio;
	    const offsetY = ((logicCanvas.height / 2) * viewportTransform[3] + viewportTransform[5]) / canvasMiniMapRatio / canvasZoomRatio;
	
	    const {x, y} = miniMap.getCenterPoint();
	    return {
	        left: x - offsetX,
	        top: y - offsetY
	    };
	};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

这段代码的核心在于offsetXoffsetY的计算公式,其目的在于将canvas中视口的对应位置等比例缩小,求得在小地图中的坐标,offsetXoffsetY是滑动窗口的左上点距离小地图中心点的位移。

最后通过left: x - offsetXtop: y - offsetY得到小地图的位置信息。

3. 为小地图更新「遮罩」

遮罩用于降低小地图中,滑动窗口之外区域的透明度,突出滑动窗口包围起来的区域。起初,我尝试了多种方式来实现它,包括给miniMap设置clipPath等,都不能完美实现。直到最后,想到了一个简单的方法。

我们先在概念上达成一致,下图中红色区域之内,蓝色区域之外的滑动窗口的部分就是「遮罩」:

这一片颜色较深的灰色区域,实际上是用4个梯形拼接而成的,为了便于说明,我将每个部分分别填充不同的颜色,加以区分:

这样,遮罩的实现就一目了然了,我们使用fabric.Polygon多边形来实现每一个梯形。这里仅列举左侧的蓝色的梯形代码:

	/**
	 * 更新小地图的遮罩区域
	 * @param miniMap 小地图实例
	 */
	const updateMiniMapMask = ({miniMap}) => {
	    // 1. 移除miniMap中所有的遮罩(fabric.Polygon)
	    let objects = miniMap.getObjects();
	    for (let i = 0; i < objects.length; i++) {
	        if (objects[i]?.type !== "rect") {
	            miniMap.remove(objects[i]);
	        }
	    }
	
	    // 2. 定义共有的属性
	    let slideWindow = miniMap.getObjects()[0];
	    const properties = {
	        opacity: 0.2,
	        hasControls: false,
	        hasBorders: false,
	        strokeWidth: 0,
	        selectable: false,
	        evented: false
	    }
	
	    // 3. 设置梯形顶点坐标
	    const polygonLeftPoints = [
	        {x: 0, y: 0},
	        {x: slideWindow.left, y: slideWindow.top},
	        {x: slideWindow.left, y: slideWindow.top + slideWindow.height},
	        {x: 0, y: miniMap.height}
	    ];
	    
	    // 4. 实例化梯形并添加到miniMap
	    let polygonLeft = new fabric.Polygon(polygonLeftPoints, properties);
	    miniMap.add(polygonLeft);
	
	    miniMap.renderAll();
	};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38

这里的代码简单易懂,没有难点,只需要注意梯形的4个顶点坐标设置正确即可。


后记

本片博文中,我们列举了小地图MiniMap,其4个核心方法中的3个。篇幅已经很长了,我把剩下的1个核心方法和将他们组合起来的部分放在下一篇博文。也希望大家在阅读和学习的过程中有个中途休息,我们缓一缓,下一篇博文再把他讲完。

如有需要,你可以:

  • 点击这里,阅读序文《前端canvas项目实战——在线图文编辑器:序》
  • 点击这里,返回上一篇《前端canvas项目实战——在线图文编辑器(九):逻辑画布》
  • 点击这里,前往下一篇《前端canvas项目实战——在线图文编辑器(十一):小地图MiniMap(下)》
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Cpp五条/article/detail/648649
推荐阅读
相关标签
  

闽ICP备14008679号