G6可视化引擎的简单上手

983 阅读1分钟

效果展示

1. 拖拽

1631610384333-0063f243-61c7-4659-bd72-abe73f5d9555.gif

2. 编辑节点内容和样式

1631610808347-76cb3abf-6d9e-4987-bf5b-624c86a2a476.gif

3. 删除

1631610938510-b82ad35c-6387-4903-b197-a1dc32527e53.gif

4. 连线、拖拽以及删除连线

1631611144575-70cd918e-18d5-4d73-a55c-f24cf1c7bcd8.gif

5. 单个节点进行配置面板

目前只研究到简单的属性配置,后续可能会有更高级的配置项

image.png

6. 概览

image.png

开发

1. 背景

文档地址:g6.antv.vision/zh/docs/des…

2. 环境与准备

1. NPM 包引入

 npm install --save @antv/g6

3. 代码

home.vue

<template>
  <div class="home-root">
    <div class="header">G6-编辑器</div>

    <!-- 左侧按钮 -->
    <item-panel />

    <!-- 挂载节点 -->
    <div
      id="canvasPanel"
      ref="canvasPanel"
      @dragover.prevent
    />

    <!-- 配置面板 -->
    <div
      id="configPanel"
      :class="{ hidden: !configVisible }"
    >
      <i
        class="gb-toggle-btn"
        @click="configVisible = !configVisible"
      />
      <h2 class="panel-title">数据配置</h2>
      <div class="config-data">
        id: {{ config.id }}, data: {{ config.data }}
      </div>
      <h2 class="panel-title">节点样式配置</h2>
      <div class="config-data">
        <div class="config-item">
          形状: <select v-model="configData.node.type">
          <option
            v-for="(item, index) in nodeShapes"
            :key="index"
            :value="item.shape"
          >
            {{ item.name }}
          </option>
        </select>
        </div>
        <div class="config-item">
          背景色: <input v-model="configData.node.style.fill">
        </div>
        <div class="config-item">
          边框虚线: <input v-model="configData.node.lineDash">
        </div>
        <div class="config-item">
          边框颜色: <input  v-model="configData.node.style.stroke">
        </div>
        <div class="config-item">
          宽: <input v-model="configData.node.style.width">px
        </div>
        <div class="config-item">
          高: <input v-model="configData.node.style.height">px
        </div>
      </div>
      <h2 class="panel-title">文字样式配置</h2>
      <div class="config-data">
        <div class="config-item">
          文字: <input v-model="configData.node.label">
        </div>
        <div class="config-item">
          字体大小: <input v-model.number="configData.node.labelCfg.fontSize">
        </div>
        <div class="config-item">
          颜色: <input v-model="configData.node.labelCfg.style.fill">
        </div>
      </div>
      <button @click="configVisible = false">取消</button>
      <button
        class="save"
        @click="save"
      >
        保存
      </button>
    </div>


  </div>
</template>

<script>
import G6 from '@antv/g6';
import ItemPanel from './ItemPanel.vue';
import registerFactory from '../../components/graph/graph';

export default {
  name: 'home',
  components: {
    ItemPanel,
  },
  data() {
    return {
      graph: {}, // 图表的载体
      configVisible: false,
      // 配置项
      configData: {
        node: {
          id: '',
          label: '',
          lineDash:    'none',
          type:       'rect-node',
          style: {
            width:       160,
            height:      60,
            fill:        '#cccccc',
            stroke: '',
            lineWidth: '',
          },
          labelCfg: {
            fontSize: 12,
            style:    {
              fill: '#fff',
            },
          },
        },


      },
      config:  {},
      // 形状选项
      nodeShapes: [
        {
          name:  '矩形',
          shape: 'rect-node',
        },
        {
          name:  '圆形',
          shape: 'circle-node',
        },
        {
          name:  '椭圆',
          shape: 'ellipse-node',
        },
        {
          name:  '菱形',
          shape: 'diamond-node',
        },
      ],
    };
  },
  props: {},
  computed: {},
  methods: {
    createGraphic () {
      const vm = this;
      // 实例化 Grid 插件 这个插件是用来绘制网格
      const grid = new G6.Grid();
      // Menu 用于配置节点上的右键菜单
      const menu = new G6.Menu({
        offsetX:   -20,
        offsetY:   -50,
        itemTypes: ['node', 'edge'],
        getContent(e) {
          const outDiv = document.createElement('div');

          outDiv.style.width = '80px';
          outDiv.style.cursor = 'pointer';
          outDiv.innerHTML = '<p id="deleteNode">删除节点</p>';
          return outDiv;
        },
        handleMenuClick(target, item) {
          const { id } = target;

          if(id) {
            vm[id](item);
          }
        },
      });
      // Minimap 是用于快速预览和探索图的工具
      const minimap = new G6.Minimap({
        size: [200, 100],
      });
      // 配置
      const cfg = registerFactory(G6, {
        width:  window.innerWidth,
        height: window.innerHeight,
        // renderer: 'svg',
        layout: {
          type: 'xxx', // 位置将固定
        },
        // 所有节点默认配置
        defaultNode: {
          type:  'rect-node',
          style: {
            radius: 10,
            width:  100,
            height: 50,
            cursor: 'move',
            fill:   '#ecf3ff',
          },
          labelCfg: {
            fontSize: 20,
            style:    {
              cursor: 'move',
            },
          },
        },
        // 所有边的默认配置
        defaultEdge: {
          type:  'polyline-edge', // 扩展了内置边, 有边的事件
          style: {
            radius:          5,
            offset:          15,
            stroke:          '#aab7c3',
            lineAppendWidth: 10, // 防止线太细没法点中
            endArrow:        true,
          },
        },
        // 覆盖全局样式
        nodeStateStyles: {
          'nodeState:default': {
            opacity: 1,
          },
          'nodeState:hover': {
            opacity: 0.8,
          },
          'nodeState:selected': {
            opacity: 0.9,
          },
        },
        // 默认边不同状态下的样式集合
        edgeStateStyles: {
          'edgeState:default': {
            stroke: '#aab7c3',
          },
          'edgeState:selected': {
            stroke: '#1890FF',
          },
          'edgeState:hover': {
            animate:       true,
            animationType: 'dash',
            stroke:        '#1890FF',
          },
        },
        modes: {
          // 支持的 behavior
          default:    ['drag-canvas', 'drag-shadow-node', 'canvas-event', 'delete-item', 'select-node', 'hover-node', 'active-edge'],
          originDrag: ['drag-canvas', 'drag-node', 'canvas-event', 'delete-item', 'select-node', 'hover-node', 'active-edge'],
        },
        plugins: [menu, minimap, grid],
        // ... 其他G6原生入参
      });

      // Graph是实例化图表
      this.graph = new G6.Graph(cfg);
      // this.graph.read(data); // 读取数据
      // this.graph.paint(); // 渲染到页面
      // this.graph.get('canvas').set('localRefresh', false); // 关闭局部渲染
      // this.graph.fitView();
    },
    // 初始化图事件
    initGraphEvent() {
      // 拖拽事件
      this.graph.on('drop', e => {
        const { originalEvent } = e;

        if(originalEvent.dataTransfer) {
          // 这里获取到侧边栏拖拽的节点的数据
          const transferData = originalEvent.dataTransfer.getData('dragComponent');

          console.log(transferData, 'transferData')
          // 添加节点
          if(transferData) {
            this.addNode(transferData, e);
          }
        }
      });

      // 在节点上释放事件
      this.graph.on('node:drop', e => {
        console.log(e, 'e1')
        e.item.getOutEdges().forEach(edge => {
          edge.clearStates('edgeState');
        });
      });

      // 选中节点后的事件
      this.graph.on('after-node-selected', e => {
        console.log(e, '选中节点')
        this.configVisible = !!e;

        if (e && e.item) {
          const model = e.item.get('model');
          console.log(model, '获取到model')

          this.config = model;
          this.configData = {
              node: {
                id: model.id,
                label: model.label,
                lineDash:    'none',
                type:  model.type,
                style: model.style,
                labelCfg: model.labelCfg,
              },
            }
        }
      });

      // 添加边之前的事件
      this.graph.on('before-edge-add', ({ source, target, sourceAnchor, targetAnchor }) => {
        setTimeout(() => {
          this.graph.addItem('edge', {
            id:     `${+new Date() + (Math.random()*10000).toFixed(0)}`, // edge id
            source: source.get('id'),
            target: target.get('id'),
            sourceAnchor,
            targetAnchor,
            // label:  'edge label',
          });
        }, 100);
      });


    },
    // 添加节点
    addNode (transferData, { x, y }) {
      const { label, shape, fill } = JSON.parse(transferData);

      const model = {
        label,
        // id:  Util.uniqueId(),
        // 形状
        type:  shape,
        style: {
          fill: fill || '#ecf3ff',
        },
        // 坐标
        x,
        y,
      };

      this.graph.addItem('node', model);
    },
    deleteNode(item) {
      this.graph.removeItem(item);
    },
    save() {
      // this.graph.addItem('node', model);

      console.log(this.configData, 'this.configData')
      this.graph.updateItem(this.config.id, {
        label: this.configData.node.label,
        type: this.configData.node.type,
        labelCfg: this.configData.node.labelCfg,
        style: {
          stroke: this.configData.node.style.stroke,
          width: Number(this.configData.node.style.width),
          height: Number(this.configData.node.style.height),
        }
      });
      this.graph.refreshItem(this.config.id)
      this.graph.refreshPositions()
      // window.alert('我觉得就算我不写你也会了');
    }
  },
  mounted() {
    // 创建画布
    this.$nextTick(() => {
      this.createGraphic();
      this.initGraphEvent();
    });
  },
  created() {
  },
};
</script>

<style scoped>
.header {
  height: 55px;
  line-height: 55px;
  position: relative;
  box-shadow: 1px 2px 3px #cccccc;
  z-index: 11;
  background: #f5f5f5;
  text-align: center;
  font-weight: bolder;
  font-size: 20px;
  margin-bottom: 20px;
}
#configPanel{
  margin-top: 20px;
  color: #902a8f;
}
</style>

ItemPanel.vue

<template>
  <div
    id="itemPanel"
    ref="itemPanel"
    :class="{'hidden': itemVisible}"
  >
    <i class="iconfont icon-h-drag" />
    <div class="icon-tool">
      <i
        draggable="true"
        data-label="方形节点"
        data-shape="rect-node"
        class="node iconfont icon-rect"
      />
      <i
        draggable="true"
        data-label="椭圆形节点"
        data-shape="ellipse-node"
        class="node iconfont icon-ellipse"
      />
      <i
        draggable="true"
        data-label="菱形节点"
        data-shape="diamond-node"
        class="node iconfont icon-diamond"
      />
      <i class="split" />
    </div>
  </div>
</template>

<script>
    export default {
        name: 'ItemPanel',
        data () {
            return {
                itemVisible: false,
            };
        },
        mounted () {
            const icons = [...this.$refs.itemPanel.querySelector('.icon-tool').querySelectorAll('.node')];

            icons.forEach(icon => {
                icon.addEventListener('dragstart', event => {
                  const shape = icon.getAttribute('data-shape');
                  const label = icon.getAttribute('data-label');
                  const fill = icon.getAttribute('fill');

                  /* 设置拖拽传输数据 */
                  event.dataTransfer.setData('dragComponent',
                      JSON.stringify({
                        label,
                        shape,
                        fill,
                      }),
                  );
                });
            });

            // 阻止默认动作
            document.addEventListener('drop', e => {
                e.preventDefault();
            }, false);
        },
    };
</script>

<style lang="scss">
#itemPanel {
  position   : absolute;
  top        : 0;
  left       : 0;
  bottom     : 0;
  z-index    : 10;
  width      : 100px;
  background : #fff;
  padding-top: 65px;
  transition : transform .3s ease-in-out;
  box-shadow : 0 0 2px 0 rgba(0, 0, 0, .1);

  &.hidden {
    transform: translate(-100%, 0);
  }

  .icon-h-drag {
    position   : absolute;
    top        : 40px;
    left       : 0;
    width      : 100%;
    height     : 20px;
    line-height: 20px;
    font-size  : 18px;
    background : #f5f5f5;
    text-align : center;
    cursor     : move;

    &:hover {
      background: #f1f1f1;
    }
  }

  .gb-toggle-btn {
    width        : 10px;
    height       : 20px;
    top          : 50%;
    left         : 100%;
    border-radius: 0 10px 10px 0;
    box-shadow   : 2px 0 2px 0 rgba(0, 0, 0, .1);
    transform    : translate(0, -50%);
  }

  .split {
    height    : 1px;
    display   : block;
    background: #e0e0e0;
    margin    : 5px 0;
  }

  .icon-tool {
    padding:10px;
    text-align    : center;
    .iconfont {
      display       : block;
      width         : 40px;
      height        : 40px;
      line-height   : 40px;
      font-size     : 30px;
      cursor        : move;
      border        : 1px solid transparent;
      margin: 0 auto;

      &:hover {
        border-color: #ccc;
      }
    }
    .node{
      display: block;
      margin-bottom: 10px;
      cursor        : move;
    }
    .circle{
      height: 80px;
      line-height: 80px;
      border-radius: 50%;
      border: 1px solid #ccc;
      background:#eef5fe;
    }
    .warning{
      height: 40px;
      line-height: 40px;
      border-left: 4px solid #E6A23C;
      background:#f8ecda;
    }
    .end{
      height: 40px;
      line-height: 40px;
      border-radius: 10px;
      background:#f9e3e2;
    }
  }
}
</style>

其它配置项

由于相关代码过多,这里就先不作展示

image.png

4. 数据存储

前端编辑出想要的图以后,有两种方式进行保存

  1. graph.save(),以数据的形式保存,优点是重新获取数据以后,可以再次编辑;缺点是成本较高,数据结构较复杂;
  2. graph.downloadFullImage()/graph.toFullDataURL(),以图片的形式保存,优点是简单方面,拿到图片直接展示;缺点是不可编辑,不够灵活。