经典可视化基础入门-底层原理篇

742 阅读22分钟

本文以canvas、svg原生为基础,结合fabricjs和antv框架讲解了2D图形原理。并深入分析了框架的构成原理,以及如何实现,并结合案例给出了多种实现方式。最后给出了整体架构图,方便参考。

canvas

developer.mozilla.org/zh-CN/docs/…

Canvas提供了一个通过JavaScript 和 HTML的<canvas>元素来绘制图形的方式,这是一种命令式的图形 API。使用体验非常类似真实世界中的绘画过程:

  • 准备一块画布。对应从页面中获取<canvas>和上下文。
  • 用笔刷蘸取调色盘中的颜料。对应设置fillStyle。
  • 绘制边框、填充颜色。对应fillRect。
// 获取 `<canvas>` 元素
const canvas = document.getElementById('canvas');
// 获取上下文
const ctx = canvas.getContext('2d');

// 设置绘制样式属性
ctx.fillStyle = 'green';
// 绘制矩形
ctx.fillRect(10, 10, 150, 100); 

SVG

developer.mozilla.org/zh-CN/docs/…

这是一种声明式的 API,基于 XML 标记语言,用于描述二维的矢量图形,使用体验非常接近 HTML。作为一个基于文本的开放网络标准,SVG 能够优雅而简洁地渲染不同大小的图形,并和CSS、DOM、JavaScript 和 SMIL 等其他网络标准无缝衔接。

  • 声明svg根节点,类似html根节点
  • 根节点可以使用内置的 rect circle path 等声明语法
<svg viewBox="0 0 220 100" xmlns="http://www.w3.org/2000/svg">
  <rect x="10" y="10" width="150" height="100" fill="green"></rect>
</svg>

WebGl (略)

developer.mozilla.org/zh-CN/docs/…

基础坐标系

  • 元素的属性(left,top)对应到二维空间的坐标系(x,y)

image (1).png

  • 元素选中UI

选中图形后需要展示可交互的 UI,用于移动、resize、旋转等交互。通过selectable属性开启:

circle.style.selectable = true;

fabric.js 中采用统一的 UI,即包围盒(并不是轴对齐的,会考虑旋转):

image.png

其他实现,比较antv在 Line、Polyline 使用折线,这样可以通过拖拽锚点改变形状:

image.png

通用属性

绘制的色值和尺寸

  • 元素的有两个色值属性 fillstoke 接受rgb(r,g,b) rgba(r,g,b,a) hsl(h,s,l) 十六进制表示法的色值。
  • 渐变同CSS定义,分为线性渐变和径向渐变两种,直接可以当作color的属性值。
    • 线性渐变
      • svg定义
      • canvas定义
<linearGradient id="Gradient1">
        <stop class="stop1" offset="0%"/>
        <stop class="stop2" offset="50%"/>
        <stop class="stop3" offset="100%"/>
</linearGradient>
createLinearGradient(x1, y1, x2, y2)
  • 径向渐变
    • svg定义
    • canvas定义
<radialGradient id="RadialGradient1">
    <stop offset="0%" stop-color="red"/>
    <stop offset="100%" stop-color="blue"/>
</radialGradient>
createRadialGradient(x1, y1, r1, x2, y2, r2)
  • 如下图所示,stoke是包含元素宽高定义+strokeWidth*2的颜色范围
  • fill仅仅包含由width和height定义的色值范围

image (2).png

基础变形

平移
  • svg采用translate <rect x="0" y="0" width="10" height="10" transform="translate(30,40)" />

  • canvas 更改(left,top)坐标系的值 rect.set({'left':200,top:200})

旋转
  • svg采用rotate <rect x="20" y="20" width="20" height="20" transform="rotate(45)" />
  • canvas修改rotate属性 rect.set('rotate',45)
斜切
  • svg采用skewX或者skewY <rect x="20" y="20" width="20" height="20" transform="skewX(45)" />
  • canvas修改skewX或者skewY属性 rect.set('skewX',45)
缩放
  • svg采用scale() <rect x="20" y="20" width="20" height="20" transform="scale(1.2,1.2)" />
  • canvas修改scaleX或者scaleY属性 rect.set('scaleX',45)

image.png

属性集合

一个元素的通用属性,一部分是控制自身,如果width,angle,一部分是控制元素在画布中的表现形式,如

  • hasBorders: true, // false 时对象的控制边界不会被渲染
  • lockRotation: false, // 旋转锁定
  • lockMovementX: false, // true”时,对象水平移动被锁定
  • lockScalingX: false, // “true”时,对象水平缩放被锁定
  • selectable: true, // 当设置为“false”时,无法选择要修改的对象(使用基于点击的选择或基于组的选择)。但事件仍在继续。
rect.set({
    width: 150, // 元素宽度
    height: 150, // 元素高度
    left: 200, //离坐标原点X轴的距离
    top: 100, //离坐标原点Y轴的距离
    angle: 0, // 设置角度
    rx: 20,  // 水平边界半径
    ry: 20,  // 垂直边界半径
    scaleX: 2, // 水平比例
    scaleY: 2, // 垂直比例
    strokeLineCap: 'butt', // 线条端点对象笔划的样式 [butt, round, square] butt
    strokeWidth: 1, // 用于渲染此对象的笔划宽度
    flipX: false, // 水平翻转
    flipY: false, // 垂直翻转
    skewX: 0, // 对象x轴上的倾斜角度(以度为单位)
    skewY: 0, // 对象x轴上的倾斜角度(以度为单位)
    stroke: '#000', // 边框
    opacity: 0.5, // 透明度
    originX: 'left', // 对象变换的水平原点:['center'|'left'|'right]
    originY: 'top', // 对象变换的水平原点:['center|'top'|'bottom']
    borderDashArray: [5, 5], // 激活状态边框样式
    borderOpacityWhenMoving: 1, // 激活且移动时,边框透明度 0.4
    centeredRotation: true, // 是否以中心旋转 true
    centeredScaling: true, // 是否以中心缩放 false
    cornerColor: 'orange', // 对象控制角的颜色,激活状态下
    cornerDashArray: [1], // 对象控制角的边框样式
    cornerSize: 20, // 角大小 13
    cornerStrokeColor: 'orange', // 对象控制角的颜色(当对象处于活动状态且透明角为假时)
    cornerStyle: 'circle', // 角状态:rect/circle
    evented: true, // false 时不能成为事件的目标,所有事件都会通过它传播
    excludeFromExport: false, // true 时不会在object/JSON中导出
    hasBorders: true, // false 时对象的控制边界不会被渲染
    hasControls: true, // false 时,对象的控件不会显示,并且不能用于操纵对象
    hoverCursor: 'pointer', // 将光标悬停在画布上的此对象上时使用的默认光标值
    includeDefaultValues: true, // 当“false”时,默认对象的值不包括在其序列化中
    lockMovementX: false, // true”时,对象水平移动被锁定
    lockMovementY: false, // 垂直锁定
    lockRotation: false, // 旋转锁定
    lockScalingFlip: false, // true”时,对象不能通过缩放为负值来翻转
    lockScalingX: false, // “true”时,对象水平缩放被锁定
    lockScalingY: false, // “true”时,对象垂直缩放被锁定
    minScaleLimit: 0.1, // 对象允许的最小比例值
    moveCursor: 'pointer', // 在画布上移动此对象时使用的默认光标值
    padding: 10, // 对象及其控制边框之间的填充(以像素为单位)
    paintFirst: 'fill', // 确定是先绘制填充还是笔划 [fill , stroke] 视觉层次区别
    selectable: true, // 当设置为“false”时,无法选择要修改的对象(使用基于点击的选择或基于组的选择)。但事件仍在继续。
    selectionBackgroundColor: 'lightblue', // 当对象处于活动状态时,对象后面的彩色层
    strokeDashArray: [5, 2], // 短间隔长虚线
    visible: true, // 当设置为“false”时,对象不会在画布上渲染
});

图形

类比HTMLDOM Element,SVG采用同HTML类似的XML声明语法,本身也是W3C的标准之一。canvas本身提供提供了绘制的基础命令,比如fillRectstrokeRectstrokefillarcarcTo 也能完成基础图形的绘制。canvas框架库基于原生canvas提供了两种发展方向,一是面向对象的发展思路,如fabricjs,完全的面向对象的命令式开发框架。另外一种借鉴react发展方向,采用声明式的组件,如konvajs。也有两种方式都支持的阿里的antd框架。++下文是结合多种框架,抽象出共同点给予讲解,一通百通,其他框架以此类推就行。++

  • 常规图形

一般的内置元素有:矩形:Rect、三角形:Triangle、图片:Image、路径:Path、文本:Text、圆:Circle、椭圆:Ellipse、线:Line、多边形:Polygon 、交叉线:Polyline

一般内置的元素使用非常简单,svg直接声明,canvas直接new,如Rect的声明:

svg:

<rect x="10" y="10" width="150" height="100" fill="green"></rect>

fabricjs:

let rect = new fabric.Rect({left:100,top:100,fill:'red',width:30,height:30});
  • Path

    可以借助path命令通过链接一系列线段、弧形实现复杂的图形。path通过一个命令字符串表示绘制的命令:

    • M:Move to命令,表示把画笔移动到某个坐标点,M大写字母,表示采用绝对定位。另一种是用m小写字母,表示采用相对定位。
    • L:Line to命令,表示将会在当前位置和新位置(L 前面画笔所在的点)之间画一条线段。
    • H:绘制水平线。
    • V:绘制垂直线。
    • Z/z:闭合路径命令,会从当前点画一条直线到路径的起点。
    • C/S :三次贝塞尔曲线。
    • Q/T:二次贝塞尔曲线 。
    • A:弧形命令,弧形可以视为圆形或椭圆形的一部分

SVG示例:

<svg width="320" height="320" xmlns="http://www.w3.org/2000/svg">
  <path d="M 10 315
           L 110 215
           A 30 50 0 0 1 162.55 162.45
           L 172.55 152.45
           A 30 50 -45 0 1 215.1 109.9
           L 315 10" stroke="black" fill="green" stroke-width="2" fill-opacity="0.5"/>
</svg>

Fabricjs: Fabric中的路径与SVG <path>元素非常相似。它们使用相同的命令,可以从<path>元素创建,并将其序列化。

var path = new fabric.Path('M121.32,0L44.58,0C36.67,0,29.5,3.22,24.31,8.41\
c-5.19,5.19-8.41,12.37-8.41,20.28c0,15.82,12.87,28.69,28.69,28.69c0,0,4.4,\
0,7.48,0C36.66,72.78,8.4,101.04,8.4,101.04C2.98,106.45,0,113.66,0,121.32\
c0,7.66,2.98,14.87,8.4,20.29l0,0c5.42,5.42,12.62,8.4,20.28,8.4c7.66,0,14.87\
-2.98,20.29-8.4c0,0,28.26-28.25,43.66-43.66c0,3.08,0,7.48,0,7.48c0,15.82,\
12.87,28.69,28.69,28.69c7.66,0,14.87-2.99,20.29-8.4c5.42-5.42,8.4-12.62,8.4\
-20.28l0-76.74c0-7.66-2.98-14.87-8.4-20.29C136.19,2.98,128.98,0,121.32,0z');

canvas.add(path.set({ left: 100, top: 200 }));

一般直接解析SVG文件,通过fabric.loadSVGFromStringfabric.loadSVGFromURL方法来加载整个SVG文件,然后让Fabric的SVG解析器完成对所有SVG元素的遍历和创建相应的Path对象的工作。

  • 文本

文本绘制属性同css可以设置font-familyfont-stylefont-weightfont-sizetext-decoration等属性。svg:

<text x="10" y="10">Hello World!</text>

同其他元素通用属性一样,和形状元素类似,属性fill可以给文本填充颜色,属性stroke可以给文本描边,形状元素和文本元素都可以引用渐变或图案。

fabricjs:

var underlineText = new fabric.Text("I'm an underlined text", {
  underline; true
});
var strokeThroughText = new fabric.Text("I'm a stroke-through text", {
  linethrough: true
});
var overlineText = new fabric.Text("I'm an overline text", {
  overline: true
});

image.png

知识点(填坑)

在Fabric.js中,可以通过 Text 或 IText;创建文字,但是文本是无法换行。Fabric.js 提供了 Textbox 类,继承自 IText;Textbox类允许用户调整文本矩形的大小并自动换行。文本框的Y比例已锁定,用户只能更改宽度。高度将根据线的环绕自动调整。

  • image

    图像的操作比较复杂,除了可以对图片进行缩放,旋转、平移、翻转等基础操作,也可以实现复杂操作,如图像滤镜、clipPaths剪切、遮罩等效果。

  1. svg加载图像同html,使用image元素定义
<svg width="5cm" height="4cm" version="1.1"
     xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <image href="firefox.jpg" x="0" y="0" height="50px" width="50px"/>
</svg>
  1. fabricjs有两种方式加载图片

一是:使用已经声明好的html image图片加载图像:

<canvas id="c"></canvas>
<img src="my_image.png" id="my-image">
var canvas = new fabric.Canvas('c');
var imgElement = document.getElementById('my-image');
var imgInstance = new fabric.Image(imgElement, {
  left: 100,
  top: 100,
  angle: 30,
  opacity: 0.85
});
canvas.add(imgInstance);

二是:远程请求一个图片,加载完毕后,使用callback处理。

fabric.Image.fromURL('my_image.png', function(oImg) {
  // 将其缩小,然后将其翻转,然后再将其添加到画布上
  oImg.scale(0.5).set('flipX, true);
  canvas.add(oImg);
});
  • 自定义图形

    在实现自定义图形上,有两个方向可以实现不同层面的需求,一是使用组合,二是使用子类化。

  1. 通过Group组合的方式实现,Group能很好的组合现有的基础图像+自定义图像,使其成为一个整体。同时在Group之上,我们可以实现子元素增删改查的API,使其使用起来更加灵活。

    • svg 的G语法,元素g是用来组合对象的容器。添加到g元素上的变换会应用到其所有的子元素上。添加到g元素的属性会被其所有的子元素继承。此外,g元素也可以用来定义复杂的对象,之后可以通过<use>元素来引用它们。

    • fabricjs 的group对象,组合是Fabric最强大的功能之一。将任何Fabric对象分组成一个单一实体的简单方法,为什么要这样做?当然是为了能够将这些对象作为一个单元来处理。可以用鼠标来将画布上的任何数量的Fabric对象进行分组,形成单一的组合形式,一旦分组,组内Fabric对象可以一起移动,甚至一起修改。他们组成一个组。我们可以缩放这个组合,旋转,甚至改变其表现性质:颜色、透明度、边界等。这正是组合所要做的,每当您在画布上看到这样的选择时,Fabric将在内部创建一组对象。只有以编程方式提供使用组的访问才有意义。这就是是fabric的组合。

<svg width="100%" height="100%" viewBox="0 0 95 50"
     xmlns="http://www.w3.org/2000/svg">
  <g stroke="green" fill="white" stroke-width="5">
    <circle cx="25" cy="25" r="15" />
    <circle cx="40" cy="25" r="15" />
    <circle cx="55" cy="25" r="15" />
    <circle cx="70" cy="25" r="15" />
  </g>
</svg>
var circle = new fabric.Circle({
  radius: 100,
  fill: '#eef',
  scaleY: 0.5,
  originX: 'center',
  originY: 'center'
});

var text = new fabric.Text('hello world', {
  fontSize: 30,
  originX: 'center',
  originY: 'center'
});

var group = new fabric.Group([ circle, text ], {
  left: 150,
  top: 100,
  angle: -10
});

canvas.add(group);
group.item(0) // 获得子对象
group.getObjects() // 获得全部子对象数组
group.size() // 组合中所有对象的数量
group.contains() // 检查某个对象是否在组合
group.add() // 新增
group.addWithUpdate() // 新增并更新
group.remove() // 删除
group.removeWithUpdate() // 删除并更新

2.面向对象方向,子类化思想:

  • fabricjs实现,在 fabric.js 中创建类,可以使用 fabric.util.createClass() 方法。_render(ctx)方法暴露出了原生canvas的context上下文,你可以自由的绘制想要的图形。
// 创建带标签功能的矩形
const LabeledRect = fabric.util.createClass(
  // 要继承的是 fabric 的矩形
  fabric.Rect,
  {
    type: 'labeledRect', // 添加一个 type 属性
    // 初始化
    initialize: function(options) {
      options || (options = {}) // 初始化参数,以免报错
      this.callSuper('initialize', options) // 继承
      this.set('label', options.label || '') // 设置 label ,默认值是空
      this.set({ width: 100, height: 50 }) // 设置默认宽高
    },
    toObject: function() {
      return fabric.util.object.extend(
        this.callSuper('toObject'),
        {
          label: this.get('label')
        }
      )
    },
    // 添加渲染时的操作
    _render: function(ctx) {
      this.callSuper('_render', ctx)
      ctx.font = this.labelFont || '20px Helevtica'
      ctx.fillStyle = this.labelFill || '#333'
      // 将 label 渲染出来
      ctx.fillText(
        this.label,
        -this.width / 2,
        -this.height / 2 + 20
      )
    }
  }
)

// 创建标签矩形
let labeledRect = new LabeledRect({
  // width: 100,
  // height: 50,
  left: 100,
  top: 100,
  label: 'test',
  fill: '#faa'
})

// 将标签矩形添加到画布中
canvas.add(labeledRect)

  • antv 底层实现细节,使用Web Components标准构建,同样是暴露define()方法,提供自定义图形。 antv提供了一些基础图形,例如 Circle、Path 等等。通过场景图能力也能构建它们之间的层次关系。但当场景层次嵌套较深又需要复用时,我们便需要一种自定义组件机制,能把这些基础图形封装成高级图形。

类似的问题在 Web Components 中是通过 Custom Element 实现的。在官方示例中我们能看到一个自定义图形的注册过程按照如下步骤进行:

  1. 在构造函数中创建内部 DOM 结构
  2. 在 connectedCallback() 即元素首次插入文档后,设置样式
  3. 在 attributeChangedCallback() 中处理属性更新,重新设置样式
  4. 使用 customElements.define() 完成自定义图形的注册

具体实现见 g-next.antv.vision/guide/advan…

事件

使用经典的Dom Event API,在实现事件API方式上,有两种选择:

  • 一种实现方式是使用标准的 rect.addEventListener('eventName',callback) rect.removeEventListener('eventName',callback),antv采用这种模式。

  • 另外一种是类似jQuery实现方式,rect.on('eventName',callback),rect.off('eventName',callback),fabricjs采用这种模式。

熟悉 DOM 事件流 的开发者对以下概念肯定不陌生:

  • 事件对象上有一个指向 EventTarget 的引用,在 DOM 中自然是 DOM 元素,在 G 中是 EventTarget。

  • 事件流包含捕获和冒泡阶段,可以通过事件对象上的某些方法介入它们。

  • 可以为某个事件添加一个或多个监听器,它们按照注册顺序依次触发。

  • 下图展示了事件传播的三个阶段,在捕获阶段自顶向下依次触发监听器,到达目标节点后向上冒泡。在监听器中可以通过 eventPhase 获取当前所处的阶段。下图来自 javascript.info/bubbling-an…

A_zJBbSL2D5mkAAAAAAAAAAAAAARQnAQ.png

antv目前支持监听如下交互事件:
  • Pointer 系列:
    • pointerdown
    • pointerup
    • pointerupoutside
    • pointertap
    • pointerover
    • pointerenter
    • pointerleave
    • pointerout
  • Mouse 系列:
    • mousedown 鼠标左键按下
    • rightdown 鼠标右键按下
    • mouseup 鼠标左键抬起
    • rightup 鼠标右键抬起
    • mouseupoutside 鼠标左键抬起时与按下时图形不同
    • rightupoutside 鼠标右键抬起与按下时图形不同
    • click 单击 & 双击 如何区分?
    • mousemove 鼠标持续在该图形上移动
    • mouseover 鼠标从该图形上移入,会冒泡
    • mouseout 鼠标从该图形上移出,会冒泡
    • mouseenter 鼠标从该图形上移入,不会冒泡
    • mouseleave 鼠标从该图形上移出,不会冒泡
    • wheel 滚轮
  • Touch 系列:
    • touchstart
    • touchend
    • touchendoutside
    • touchmove
    • touchcancel
  • 目前我们支持如下场景图相关事件:
    • CHILD_INSERTED 作为父节点有子节点添加时触发
    • INSERTED 作为子节点被添加时触发
    • CHILD_REMOVED 作为父节点有子节点移除时触发
    • REMOVED 作为子节点被移除时触发
    • MOUNTED 首次进入画布时触发
    • UNMOUNTED 从画布中移除时触发
    • ATTR_MODIFIED 修改属性时触发
    • DESTROY 销毁时触发

示例代码:

import { ElementEvent } from '@antv/g';

canvas.addEventListener(ElementEvent.MOUNTED, (e) => {
    e.target;
});
circle.addEventListener('mouseenter', () => {
    circle.attr('fill', '#2FC25B');
});
circle.addEventListener('mouseleave', () => {
    circle.attr('fill', '#1890FF');
});
fabricjs 事件体系,分canvas事件类antv场景图相关事件,和子组件事件:

事件演示地址: http://fabricjs.com/events

  • canvas事件

    • object:modified at the end of a transform or any change when statefull is true
    • object:rotating while an object is being rotated from the control
    • object:scaling while an object is being scaled by controls
    • object:moving while an object is being dragged
    • object:skewing while an object is being skewed from the controls
    • before:transform before a transform is is started
    • before:selection:cleared
    • selection:cleared
    • selection:updated
    • selection:created
    • path:created after a drawing operation ends and the path is added
    • mouse:down
    • mouse:move
    • mouse:up
    • mouse:down:before on mouse down,event: before the inner fabric logic runs
    • mouse:move:before on mouse move,event: before the inner fabric logic runs
    • mouse:up:before on mouse up,event: before the inner fabric logic runs
    • mouse:over
    • mouse:out
    • mouse:dblclick whenever a native dbl click event fires on the canvas.
    • event:dragover
    • event:dragenter
    • event:dragleave
    • drop:before before drop event. same native event.event: This is added to handle edge cases
    • event:drop
    • after:render at the end of the render process,event: receives the context in the callback
    • before:render at start the render process,event: receives the context in the callback
  • 子组件事件

    • moving
    • scaling
    • rotating
    • skewing
    • resizing
    • mouseup
    • mousedown
    • mousemove
    • mouseup:before
    • mousedown:before
    • mousemove:before
    • mousedblclick
    • mousewheel
    • mouseover
    • mouseout
    • drop:before
    • drop
    • dragover
    • dragenter
    • dragleave

示例代码:

var canvas = new fabric.Canvas('...');
canvas.on('mouse:down', function(options) {
  console.log(options.e.clientX, options.e.clientY);
});
var rect = new fabric.Rect({ width: 100, height: 50, fill: 'green' });
rect.on('selected', function() {
  console.log('selected a rectangle');
});

var circle = new fabric.Circle({ radius: 75, fill: 'blue' });
circle.on('selected', function() {
  console.log('selected a circle');
});

动画

动画实现还是参考了Web Animations API,antv是按照Web Animations API实现了自己一整套的动画体系,fabricjs则是封装到animate方法中。下面看下两者的实现。

  • antv支持基于 Keyframe 的动画,用户需要定义一系列关键帧,其中每一帧都可以包含变换属性、帧偏移量、缓动函数等参数,G 内部通过插值得到各个属性值在当前时间下的值并应用到目标图形上(如下图)。另外,对一些特殊属性变换会带来特别的动画效果,例如:

    • offsetDistance 属性可以实现路径动画
    • lineDashOffset 属性可以实现蚂蚁线动画
    • lineDash 属性可以实现笔迹动画
    • Path 的 path 属性可以实现形变动画(Morph)
const scaleInCenter = circle.animate(
    [
        {
            transform: 'scale(0)', // 起始关键帧
        },
        {
            transform: 'scale(1)', // 结束关键帧
        },
    ],
    {
        duration: 500, // 持续时间
        easing: 'cubic-bezier(0.250, 0.460, 0.450, 0.940)', // 缓动函数
        fill: 'both', // 动画处于非运行状态时,该图形的展示效果
    },
);

熟悉 CSS Transform/Animation 的开发者一定不会陌生。其对应的 CSS Animation 为:

.scale-in-center {
    animation: scale-in-center 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
}
@keyframes scale-in-center {
    0% {
        transform: scale(0);
    }
    100% {
        transform: scale(1);
    }
}

image (3).png

  • fabric实现模式与之类型
rect.animate('left', 500, {
  onChange: canvas.renderAll.bind(canvas),
  duration: 1000,
  easing: fabric.util.ease.easeOutBounce
});

数据持久化

在数据持久化方向,各个框架层的实现思路是一致的,有两种模式可以实现。

  • 一种是实例化到数据库中,能实现数据的存取,有能力取出数据并还原出画布内容。json格式是作为js方向的首选的序列化格式,借助与类似的fromJson加载数据库资源数据,toJson导出存储画布内容数据。借助json的层次关系,既可以整个数据存储,也可以分段存储。

  • 另外一种是导出svg文件,XML也可以存储数据,另外是导出.png、.jpg,等图片或者base64格式的字符串。

antv的实现方式
  • 导出 dataURL

通过 toCanvas 我们得到了包含画布内容的 HTMLCanvasElement,使用其原生方法 toDataURL 就可以得到 dataURL:

const canvas = await exporter.toCanvas();
const dataURL = canvas.toDataURL(); // data:...

在 toDataURL 方法中可以指定图片格式,默认为 image/png,以及图片质量,详见参数。

  • 导出 ImageData

HTMLCanvasElement 同样提供了 getImageData 方法用于获取指定区域的像素数据:

const canvas = await exporter.toCanvas();
const imageData = canvas.getContext('2d').getImageData(50, 50, 100, 100); // ImageData { width: 100, height: 100, data: Uint8ClampedArray[40000] }
  • 导出 PDF

如果我们还想在前端根据图片生成 PDF,可以参考:github.com/parallax/js…

  • 序列化/反序列化

提供了 graph.toJSON()graph.fromJSON() 两个方法来序列化和反序列化图。

toJSON的数据格式

{
  "cells": [
    {
      "attrs": {
        "text": {
          "text": "Hello"
        },
        "body": {
          "rx": 10,
          "ry": 10
        }
      },
      "shape": "rect",
      "id": "07a2e767-da40-4560-add5-3dc504e4fafc",
      "zIndex": 1
    },
    {
      "attrs": {
        "text": {
          "text": "World"
        }
      },
      "shape": "ellipse",
      "id": "e68128dd-ef10-4b3a-9f02-6b154022f9c4",
      "zIndex": 2
    },
    {
      "shape": "edge",
      "id": "25d2eb0f-76ff-4412-a65c-d676aec603be",
      "source": {
        "cell": "07a2e767-da40-4560-add5-3dc504e4fafc"
      },
      "target": {
        "cell": "e68128dd-ef10-4b3a-9f02-6b154022f9c4"
      },
      "zIndex": 3
    }
  ]
}

graph.fromJSON(cells: (Node.Metadata | Edge.Metadata)[])。

graph.fromJSON([
  {
    id: 'node1',
    x: 40,
    y: 40,
    width: 100,
    height: 40,
    label: 'Hello',
    shape: 'rect',
  },
  {
    id: 'node2',
    x: 40,
    y: 40,
    width: 100,
    height: 40,
    label: 'Hello',
    shape: 'ellipse',
  },
  {
    id: 'edge1',
    source: 'node1',
    target: 'node2',
    shape: 'edge',
  }
])
fabricjs的实现方式
  • 序列化:Fabric中的canvas序列化方法主要是toObject()toJSON()方法
canvas.add(new fabric.Rect({
  left: 50,
  top: 50,
  height: 20,
  width: 20,
  fill: 'green'
}));
canvas.toObject();
console.log(JSON.stringify(canvas));

输出的数据:

{ "background" : "rgba(0, 0, 0, 0)",
  "objects" : [
    {
      "angle" : 0,
      "fill" : "green",
      "flipX" : false,
      "flipY" : false,
      "hasBorders" : true,
      "hasControls" : true,
      "hasRotatingPoint" : false,
      "height" : 20,
      "left" : 50,
      "opacity" : 1,
      "overlayFill" : null,
      "perPixelTargetFind" : false,
      "scaleX" : 1,
      "scaleY" : 1,
      "selectable" : true,
      "stroke" : null,
      "strokeDashArray" : null,
      "strokeWidth" : 1,
      "top" : 50,
      "transparentCorners" : true,
      "type" : "rect",
      "width" : 20
    }
  ]
}

另一种高效的基于文本的画布表示是SVG格式,借助toSVG()。由于Fabric专门从事画布上的SVG解析和渲染,所以将其作为双向过程并提供画布到SVG转换是有意义的。

canvas.add(new fabric.Rect({
  left: 50,
  top: 50,
  height: 20,
  width: 20,
  fill: 'green'
}));
console.log(canvas.toSVG());
'<?xml version="1.0" standalone="no" ?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="800" height="700" xml:space="preserve"><desc>Created with Fabric.js 0.9.21</desc><rect x="-10" y="-10" rx="0" ry="0" width="20" height="20" style="stroke: none; stroke-width: 1; stroke-dasharray: ; fill: green; opacity: 1;" transform="translate(50 50)" /></svg>'
  • 反序列化:与序列化类似,有两种方式从字符串加载canvas:从JSON表示,或从SVG。当使用JSON表示时,使用loadFromJSON和oadFromDatalessJSON方法。SVG时,使用fabric.loadSVGFromURL和fabric.loadSVGFromString。
var canvas = new fabric.Canvas();

canvas.loadFromJSON('{"objects":[{"type":"rect","left":50,"top":50,"width":20,"height":20,"fill":"green","overlayFill":null,"stroke":null,"strokeWidth":1,"strokeDashArray":null,"scaleX":1,"scaleY":1,"angle":0,"flipX":false,"flipY":false,"opacity":1,"selectable":true,"hasControls":true,"hasBorders":true,"hasRotatingPoint":false,"transparentCorners":true,"perPixelTargetFind":false,"rx":0,"ry":0},{"type":"circle","left":100,"top":100,"width":100,"height":100,"fill":"red","overlayFill":null,"stroke":null,"strokeWidth":1,"strokeDashArray":null,"scaleX":1,"scaleY":1,"angle":0,"flipX":false,"flipY":false,"opacity":1,"selectable":true,"hasControls":true,"hasBorders":true,"hasRotatingPoint":false,"transparentCorners":true,"perPixelTargetFind":false,"radius":50}],"background":"rgba(0, 0, 0, 0)"}');

实战

拖拽实现细节

要实现元素可以拖拽到画布上,在实现的细节上是一致的,都需要处理三种状态的节点数据,整体拖拽流程是:源节点 -> 拖拽节点 -> 放置节点。antv的X6实现参考:x6.antv.vision/zh/docs/tut…,他借助dnd插件实现的。

今天需要介绍的是陈列管理所使用fabricjs实现细节,主要借助HTML原生事件drag drop,下面来拆解实现细节。

原节点:其实这部分数据是真实的HTML元素,具体的形状可以是:

  1. html元素,通过css属性构建基本形状。
  2. image图片,可以加载远程图片。
  3. canvas子元素,可以加载绘制好的子canvas元素。

拖拽节点:有两种选择,具体分析可以见 juejin.cn/post/720032…

  1. html drag 的默认行为获取可视节点的copy图像。
  2. 使用dataTransfer.setDragImage(img, xOffset, yOffset)可以自定义图形

放置节点: 是实际fabricjs需要绘制的元素,fabricjs需要根据drag事件传递的数据来绘制实际在画布上的元素。

image (4).png

拖拽细节-以陈列管理打包台为例
  • 开始拖拽时,源节点是一个图片,一个DIV的html元素,使一个元素可以拖拽根据html drag规范,一是需要在html元素设置draggable属性,二是需要设置onDragStart事件。需要特别注意的是:绘制的数据存储在html的**data-**标签上。
        <div
            className={styles.packageItem}
            draggable
            onDragStart={onPackageDragStart}
            data-set='{"devicetype":5,"devicewidth":97,"devicelength":96,"deviceheight":100}'
          >
            <IconFont type="icon-icon_package" className={styles.icon} style={{ fontSize: 45 }} />
            <span>打包台</span>
          </div>
  • 拖拽过程中,首先我们拿到DragEventdataTransfer属性,设置dropEffect模式,,然后获取HtmlDivElementdataset,中间的秘密在于drag和drop的通信是通过dt.setData('text/plain', JSON.stringify(drawData));,最后根据数据绘制中间节点。
  // 打包台 dragStart事件
  const onPackageDragStart = useCallback((event: React.DragEvent<HTMLDivElement>) => {
    const dt = event.dataTransfer;
    dt.dropEffect = 'copy';
    const div = event.target as HTMLDivElement;
    const data = div.dataset.set;
    if (data) {
      const drawData: API.EquipmentInfo = JSON.parse(data);
      drawData.shelfcode = '打包台-' + generateUuid();
      drawData.devicetype = 5;
      drawData.devicetypename = '打包台';
      dt.setData('text/plain', JSON.stringify(drawData));
      console.log('package data', drawData);
      drawPackageDragImage(dt, drawData);
    }
  }, []);
  • 拖拽结束时,在canvas上监听drop事件,这时候需要用到绘制实际的元素信息,有两个来源,
  1. 节点本身的形状信息来源于DragEventdataTransfer let data = dataTransfer.getData('text/plain');,数据是通过拖拽过程中DragEventdataTransfer设置的。
  2. 元素的坐标信息,来源于dragEvent的坐标点信息,还需要根据形状的宽高计算出中心点坐标。然后绘制出实际的节点性状。
canvas.on('drop', (event: fabric.IEvent<MouseEvent>) => {
    const dragEvent = event.e as DragEvent;
    const dataTransfer = dragEvent.dataTransfer;
    if (dataTransfer) {
      let data = dataTransfer.getData('text/plain');
      const drawItemObj: API.EquipmentInfo = JSON.parse(data);
      if (drawItemObj) {
        const fabricItem = dropDragObject(canvas, dragEvent, drawItemObj);
        console.log('fabricItem', fabricItem);
        // 禁止元素绘制到画布外面
        if (
          fabricItem.left !== undefined &&
          fabricItem.left > 0 &&
          fabricItem.left < canvas.getWidth() - fabricItem.getScaledWidth() &&
          fabricItem.top !== undefined &&
          fabricItem.top > 0 &&
          fabricItem.top < canvas.getHeight() - fabricItem.getScaledHeight()
        ) {
          // 删除左边元素 增加canvas元素
          canvas.add(fabricItem);
          insertEquipment(drawItemObj);
          addRemoteFabricObject(fabricItem);
        }
      }
    }
    });

禁止元素超出画布范围 (自行学习)

juejin.cn/post/723107…

总结

整体架构如下:

image (5).png

参考代码

import * as React from "react";
import { useState, useEffect, useCallback, useRef } from "react";
// 引入 G 核心中的画布、各种 2D/3D 图形
import { Canvas, CanvasEvent, Circle, Rect, Line, Text } from "@antv/g";
// 选择一个或多个 Renderer
import { Renderer as SvgRenderer } from "@antv/g-svg";
import { Renderer as CanvasRenderer } from "@antv/g-canvas";
import { Renderer as WebglRenderer } from "@antv/g-webgl";

import interact from "interactjs";
import { Space, Button } from "antd";

const GBase: React.FC = () => {
  const containerRef = useRef<HTMLDivElement>();
  const [canvas, setCanvas] = useState<Canvas>();

  const init = useCallback(() => {
    const canvas = new Canvas({
      container: containerRef.current,
      width: 1000,
      height: 500,
      background: "#F1F1F1",
      renderer: new CanvasRenderer(),
    });
    const circle = new Circle({
      style: {
        r: 50,
        cx: 55,
        cy: 55,
        fill: "#1890FF",
        stroke: "#F04864",
        lineWidth: 4,
        cursor: "pointer",
        zIndex: 1,
      },
    });
    const circleText = new Text({
      style: {
        text: "Node1",
        fontSize: 20,
        fill: "#FFFFFF",
        textAlign: "center",
        textBaseline: "middle",
      },
    });
    circle.appendChild(circleText);
    circle.addEventListener("mouseenter", () => {
      circle.style.fill = "red";
    });
    circle.addEventListener("mouseleave", () => {
      circle.style.fill = "#1890FF";
    });

    const rect = new Rect({
      style: {
        x: 350,
        y: 100,
        width: 100,
        height: 50,
        fill: "#FFFFFF",
        stroke: "red",
        cursor: "pointer",
        zIndex: 1,
      },
    });
    const rectText = new Text({
      style: {
        x: 21,
        y: 30,
        text: "Node2",
        fontSize: 20,
        fill: "red",
        //textAlign: "left",
        //textBaseline: "bottom",
      },
    });

    const edge = new Line({
      style: {
        x1: 50,
        y1: 50,
        x2: 380,
        y2: 130,
        stroke: "#1890FF",
        lineWidth: 2,
      },
    });

    const text = new Text({
      style: {
        x: 100,
        y: 200,
        text: "Node3",
        fontSize: 20,
        fill: "red",
        textAlign: "left",
        textBaseline: "bottom",
      },
    });

    rect.appendChild(rectText);

    // 使用 World API 描述场景
    canvas.addEventListener(CanvasEvent.READY, () => {
      canvas.appendChild(circle);
      canvas.appendChild(rect);
      canvas.appendChild(edge);
      canvas.appendChild(text);
      interact(circle as any, { context: canvas.document as any }).draggable({
        onmove: (event) => {
          // interact.js 告诉我们的偏移量
          const { dx, dy } = event;
          // 改变节点1位置
          circle.translateLocal(dx, dy);
          // 获取节点1位置
          const [nx, ny] = circle.getLocalPosition();
          // 改变边的端点位置
          edge.style.x1 = nx;
          edge.style.y1 = ny;
        },
      });
    });
    setCanvas(canvas);
  }, []);

  useEffect(() => {
    init();
  }, []);

  const changeSvg = useCallback(() => {
    console.log("changeSvg");
    canvas.setRenderer(new SvgRenderer());
    canvas.render();
  }, [canvas]);

  const changeCanvas = useCallback(() => {
    canvas.setRenderer(new CanvasRenderer());
  }, [canvas]);

  const changeWebgl = useCallback(() => {
    canvas.setRenderer(new WebglRenderer());
  }, [canvas]);

  return (
    <div className="main-content">
      <h2>GBase</h2>
      <div style={{ marginBottom: 10 }}>
        <Space>
          <Button onClick={changeSvg}>change svg</Button>
          <Button onClick={changeCanvas}>change canvas</Button>
          <Button onClick={changeWebgl}>change webgl</Button>
        </Space>
      </div>
      <div ref={containerRef}></div>
    </div>
  );
};

export default GBase;