The first brief book, this is the consolidated version. The code link is at the end of the article

Recently, the company needs to do a machine learning platform for internal use, which has a part of the demand can be abstract as directed acyclic graph, while stepping on the pit while recording the research and development process (in fact, it is not the finished wheel of the high coupling business 🀷), if there is a similar demand, you may wish to bubble cup wolfberry, slowly read this article.

The tutorial implementation includes:

Drag model node to establish relationship (wire)

External operations of model nodes (addition and deletion of nodes, DAG ring detection implemented at the front end)

Plane movement of the whole image of the model (full image enlargement, box selection, full screen, etc.)

On the technology selection of front-end visualization.

To meet the initial requirements, consider using SVG and Canvas to achieve this content. In summary:

The name of the svg canvas
Image quality A vector graph can be scaled at will Bitmap, zoom distortion
event-driven Bind the event Easy based on the DOM element Script-driven and inflexible event configuration
performance As above, too many render elements can cause stuttering High performance, more off-screen canvas trend in the future
Applicable scenario More interaction and less image Rendering of multiple repeating elements
Learning costs Relatively simple There is a cost to getting started

Therefore, SVG is used as a whole, and there are many products based on SVG in the market, such as Ink Knife, Processon, Noflo, and many platforms of Alibaba, which perform quite well in some scenarios (of course, it is also convenient to learn how to write code at any time).

The book picks up and cuts back to the point

I. Implementation of nodes

{
    name: "name1",
    description: "description1",
    id: 1,
    parentNode: 0,
    childNode: 2,
    imgContent: "",
    parentDetails: {
      a: "",
      b: ""
    },
    linkTo: [{ id: 2 }, { id: 3 }],
    translate: {
      left: 100,
      top: 20
    }
  }
Copy the code

The later stage (after step5 of the tutorial) is optimized as:

{
    name: "name2",
    id: 2,
    imgContent: "",
    pos_x: 300,
    pos_y: 400,
    type: 'constant',
    in_ports: [0, 1, 2, 3, 4],
    out_ports: [0, 1, 2, 3, 4]
 }
Copy the code

Ignore the abstractions of the soul draftsman, everything is data-driven, and the model nodes just need to interact with the back-end development as shown above.

2. Realization of model node connection

<path
  class="connector"
  v-for="(each, n) in item.linkTo" :key="n"
  :d="computedLink(i, each, n)">
</path>
Copy the code

Based on VUE realization, d is directly used to dynamically calculate The Bezier curve. The idea is to calculate the starting position by using the ID of the entry and exit nodes and assign the value to the curve formula Click – > about Bessel curve may refer to https://brucewar.gitbooks.io/svg-tutorial/15.SVG-path%E5%85%83%E7%B4%A0.html

Three, the implementation of node drag

DragPre (e, I) {// Prepare to drag the node this.setinitRect (); // Initialize the artboard coordinates this.currentevent ="dragPane"; This.choice. index = I; this.setDragFramePosition(e); },Copy the code

The reason for initializing the artboard: Since the position of the elements in the window is not fixed, the initial coordinates are needed each time to facilitate the calculation of the relative displacement.

<g
        :transform="`translate(${dragFrame.posX}.${dragFrame.posY}) `"
        class="dragFrame">
          <foreignObject width="180" height="30" >
            <body xmlns="http://www.w3.org/1999/xhtml">
              <div
              v-show="currentEvent === 'dragPane'"
              class="dragFrameArea">
              </div>
            </body>
          </foreignObject>
        </g>
Copy the code

Mousedown gets the subscript of the drag element and modifies the coordinates

dragIng(e) {
      if (this.currentEvent === "dragPane") { this.setDragFramePosition(e); }},setDragFramePosition(e) { const x = e.x - this.initPos.left; Const y = e.y-this.initpos.top; this.dragFrame = { posX: x - 90, posY: y - 15 }; }Copy the code

Assign a position to the element that simulates dragging when dragging

DragEnd (e) {// Drag to endif (this.currentEvent === "dragPane") {
        this.dragFrame = { dragFrame: false, posX: 0, posY: 0 }; this.setPanePosition(e); } this.currentEvent = null; // Clear the event behavior},setPanePosition(e) {
      const x = e.x - this.initPos.left - 90;
      const y = e.y - this.initPos.top - 15;
      const i = this.choice.index;
      this.DataAll[i].translate = { left: x, top: y };
    },
Copy the code

Drag the end of the new location assigned to the corresponding element, of course, in the actual project, each change needs to interact with the background of these data, do not need the front-end simulation data change, direct request the whole graph interface re rendering is good, more easy

Four, node connection drag implementation

Similar to the previous step, we listen for events like mousedown Mousemove and Mouseup. To achieve the effect of drag and drop between nodes. The only difficulty is figuring out where to start.

<g>
          <path
          class="connector"
          :d="dragLinkPath()"
          ></path>
        </g>
Copy the code

Let's start with path

setInitRect() {
      let { left, top } = document
        .getElementById("svgContent") .getBoundingClientRect(); this.initPos = { left, top }; }, linkPre(e, I) {this.setinitRect (); this.currentEvent ="dragLink";
      this.choice.index = i;
      this.setDragLinkPostion(e, true);
      e.preventDefault();
      e.stopPropagation();
    },
Copy the code

Mousedown modifies coordinates

dragIng(e) {
      if (this.currentEvent === "dragLink") { this.setDragLinkPostion(e); }},Copy the code

Determine your position while mousemove

linkEnd(e, i) {
      if (this.currentEvent === "dragLink") {
        this.DataAll[this.choice.index].linkTo.push({ id: i });
        this.DataAll.find(item => item.id === i).parentNode = 1;
      }
      this.currentEvent = null;
    },
    setDragLinkPostion(e, init) {const x = e.x-this.initpos.left; const y = e.y - this.initPos.top;if (init) {
        this.dragLink = Object.assign({}, this.dragLink, {
          fromX: x,
          fromY: y
        });
      }
      this.dragLink = Object.assign({}, this.dragLink, { toX: x, toY: y });
    },
Copy the code

Determine which element is attached to the mouseup

Five, the integration of the above steps, components out

As the content grows, we need to consolidate everything together and split components based on coupled content, depending on the directory structure

Six, node drag add implementation

Β· Before dragging, judge whether it can be dragged in the current situation and the node type and node name carried by the dragged element

Β· Drag the simulated node to move with the mouse, and assign parameters to the simulated node

Drag, stop judge whether let go location in the drawing board, (change the model data backend interface) | call

So we need a simulation element that can move full screen as shown in class=’nodesBus-contain’

<nodes-bus v-if="dragBus" :value="busValue.value" :pos_x="busValue.pos_x" :pos_y="busValue.pos_y" />
Copy the code

This element is second only to the largest container in the global DOM, receiving coordinate position and display name.

      dragBus: false,
      busValue: {
        value: "name",
        pos_x: 100,
        pos_y: 100
      }
Copy the code

The outermost component uses dragBus to control display, location, etc.

  <div class="page-content" @mousedown="startNodesBus($event)" @mousemove="moveNodesBus($event)" @mouseup="endNodesBus($event)">
Copy the code

The outer container contains three events, mouseDown, mouseMove, and mouseUp

<span @mousedown="DragIt (drag '1')"<span > <span @mousedown="DragIt (' drag 2 ')"</span> dragIt(val) {sessionStorage["dragDes"] = JSON.stringify({
        drag: true,
        name: val
      });
    }
Copy the code

Elements that need to be clicked to trigger a drag use the cache to pass data and control the mock nodes.

StartNodesBus (e) {** ** dragDes: {* drag:true, * name: component name *type: Component type * model_id: used to interact with background *} **/let dragDes = null;
      if (sessionStorage["dragDes"]) {
        dragDes = JSON.parse(sessionStorage["dragDes"])}if (dragDes && dragDes.drag) {
        const x = e.pageX;
        const y = e.pageY;
        this.busValue = Object.assign({}, this.busValue, {
          pos_x: x,
          pos_y: y,
          value: dragDes.name
        });
        this.dragBus = true; }}Copy the code

Bubbling to the topmost component triggers the container’s mouseUp event, causing the simulated node to be displayed and assigning the desired parameters. Caching is used to control behavior to prevent interference from unrelated elements.

moveNodesBus(e) {
      if(this.dragBus) { const x = e.pageX; const y = e.pageY; this.busValue = Object.assign({}, this.busValue, { pos_x: x, pos_y: y }); }},Copy the code

The behavior in the movement is very simple, just need to dynamically mouse the page position to enter.

endNodesBus(e) {
      let dragDes = null;
      if (sessionStorage["dragDes"]) {
        dragDes = JSON.parse(sessionStorage["dragDes"])}if (dragDes && dragDes.drag && e.toElement.id === "svgContent") {
        const { model_id, type} = dragDes; const pos_x = e.offsetX - 90; // Const pos_y = e.offty-15; Const params = {model_id: sessionStorage[const params = {model_id: sessionStorage["newGraph"],
          desp: {
            type,
            pos_x,
            pos_y,
            name: this.busValue.value
          }
        };
        this.addNode(params);
      }
      window.sessionStorage["dragDes"] = null;
      this.dragBus = false;
    }
Copy the code

This. AddNode (Params) is called from Vuex. Vuex will be explained in a unified way later.

7. Node deletion

        <g
        v-for="(item, i) in DataAll.nodes"
        :key="'_' + i" class="svgEach"
        :transform="`translate(${item.pos_x}.${item.pos_y}) `"
        @contextmenu="r_click_nodes($event, i)"> --------------------------------------------------------------------------- r_click_nodes(e, I) {this.setinitRect () const id = this.dataall.nodes [I].id; const x = e.x - this.initPos.left; const y = e.y - this.initPos.top; this.is_edit_area = { value:true,
        x,
        y,
        id
      }
      e.stopPropagation();
      e.cancelBubble = true;
      e.preventDefault();
    }
Copy the code

Then pass the node ID and mouse position of the operation to the option emulation component Nodesbus.vue to ensure that the option box appears in the right place. There is also a pit, and we want to make sure that clicking somewhere else closes the modal box, so we need to add a mask, and here I have taken the advantage of not adding a cover div

 <foreignObject width="100%" height="100%" style="position: relative" @click="click_menu_cover($event)">
        <body xmlns="http://www.w3.org/1999/xhtml" :style="get_menu_style()">
            <div class="menu_contain">
                <span @click="delEdges"</span> <span> edit </span> <span> do something else </span> </div> </body> </foreignObject> ------------------------------------------------- click_menu_cover(e) { this.$emit('close_click_nodes')
      e.preventDefault();
      e.cancelBubble = true;
      e.stopPropagation();
 },
Copy the code

Block mouseDown directly inside the component to close the popup.

let params = {
        model_id: sessionStorage['newGraph'],
        id: this.isEditAreaShow.id
 }
this.delNode(params)
Copy the code

Model_id is the parameter of interaction between this project and the background. Please call the delNode of VUex directly without getting the ID

8. Connecting, deleting nodes and using VUEX

In order to make component division more detailed and facilitate data sharing among components, VUEX is introduced as the data of this project. Multiple components working together with DagStore.js DataAll,

AddEdge: ({commit}, {desp}) => {// addEdge commit('ADD_EDGE_DATA', desp)}, delEdge: ({commit}, {id}) = > {/ / remove edge commit ('DEL_EDGE_DATA', id)}, moveNode: ({commit}, params) => {commit('MOVE_NODE_DATA', params)}, addNode: ({commit}, params) => {// addNode commit('ADD_NODE_DATA', params)}, delNode: ({commit}, {id}) => {// delete node commit('DEL_NODE_DATA', id)
    },
Copy the code

The data structure of state is

DataAll: {
      nodes: [{
        name: "name5",
        id: 1,
        imgContent: "",
        pos_x: 100,
        pos_y: 230,
        type: "constant",
        in_ports: [0, 1, 2],
        out_ports: [0, 1, 2, 3, 4]
      }],
      edges: [{
        id: 1,
        dst_input_idx: 1,
        dst_node_id: 1,
        src_node_id: 2,
        src_output_idx: 2
      }],
      model_id: 21
    }
Copy the code

All you need to do is change DataAll in State.

ADD_NODE_DATA: (state, params) => {
      let_nodes = state.DataAll.nodes _nodes.push({ ... params.desp, id: state.DataAll.nodes.length + 10, in_ports: [0, 1, 2, 3, 4], out_ports: [0, 1, 2, 3, 4] }) }Copy the code

New nodes

DEL_NODE_DATA: (state, id) => {
      let _edges = []
      let _nodes = []
      state.DataAll.edges.forEach(item => {
        if(item.dst_node_id ! == id && item.src_node_id ! == id) { _edges.push(item) } }) state.DataAll.nodes.forEach(item => {if(item.id ! == id) { _nodes.push(item) } }) state.DataAll.edges = _edges state.DataAll.nodes = _nodes }Copy the code

The node to delete

DEL_EDGE_DATA: (state, id) => {
      let _edges = []
      state.DataAll.edges.forEach((item, i) => {
        if(item.id ! == id) { _edges.push(item) } }) state.DataAll.edges = _edges },Copy the code

Clear connections between nodes

ADD_EDGE_DATA: (state, desp) => {
      let_DataAll = state.DataAll _DataAll.edges.push({ ... Desp, id: state DataAll. Edges. Length + 10}) / * * / * * * detect cyclizationlet isCircle = falseConst {dst_node_id} = desp const checkCircle = (dst_node_id, NTH) => {if (nth > _DataAll.nodes.length) {
          isCircle = true
          return false
        } else {
          _DataAll.edges.forEach(item => {
            if (item.src_node_id === dst_node_id) {
              console.log('Target node is', item.src_node_id, 'Degree is', nth)
              checkCircle(item.dst_node_id, ++nth)
            }
          })
        }
      }
      checkCircle(dst_node_id, 1)
      if (isCircle) {
        _DataAll.edges.pop()
        alert('Forbid loop formation')}}Copy the code

The above code is the increase of nodes, which adds a detection of whether the loop is formed, constantly recursive nodes, looking for node paths from the target node, if the number of cycles exceeds the total number of nodes, it proves that the loop has appeared, cancel the operation.

In the actual project, each step of the operation can be passed to the back end, so the front end does not have a lot of calculation, and the back end students are responsible for the calculation in the cache

Nine, the whole drag implementation

SvgMouseDown (e) {// SVG mouse down triggers event distribution this.setinitrect ();if (this.currentEvent === "sel_area") {
        this.selAreaStart(e);
      } else{// Then drag the canvas this.currentevent ="move_graph"; this.graphMovePre(e); }},Copy the code

Event triggering: Event distribution occurs when the SVG canvas mousedown

/** * graphMovePre(e) {const {x, y} = e; this.svg_trans_init = { x, y }; this.svg_trans_pre = { x: this.svg_left, y: this.svg_top }; }, graphMoveIng(e) { const { x, y } = this.svg_trans_init; this.svg_left = e.x - x + this.svg_trans_pre.x; this.svg_top = e.y - y + this.svg_trans_pre.y; sessionStorage["svg_left"] = this.svg_left;
      sessionStorage["svg_top"] = this.svg_top;
    },
Copy the code

Monitor the mouse dynamic changes during mousemove and change the current position of the canvas by comparing the initial position of the mousedown

Ten, the realization of the whole map zoom & current mouse position calculation of the original coordinates

In the same 11, the overall scaling of nodes is carried out through the transform: scale(x) of g tag below SVG

    <g :transform="` translate(${svg_left}.${svg_top}) scale(${svgScale}) `" >
Copy the code

Here svgScale uses VUEX to control, in order to prove that there is no unified standard for state management of components, but it is still strongly recommended that state be handed over to components and data be handed over to VUex. ↓↓

    svgScale: state => state.dagStore.svgSize
Copy the code

Here added a floating bar component, convenient for user operation.

<template>
     <g>
        <foreignObject width="200px" height="30px" style="position: relative">
        <body xmlns="http://www.w3.org/1999/xhtml">
            <div class="control_menu">
                <span @click="sizeExpend"> β•‹ < / span > < span @ click ="sizeShrink">δΈ€</span>
                <span @click="sizeInit"> ╬ < / span > < span class ="['sel_area', 'sel_area_ing'].indexOf(currentEvent) !== -1 ? 'sel_ing' : ''" @click="sel_area($event)">口</span>
                <span @click="fullScreen">{{ changeScreen }}</span>
            </div>
        </body>
        </foreignObject>
    </g>
</template>
Copy the code
/** * SVG artboard scaling behavior */sizeInit() {
      this.changeSize("init"); // return to the default multiple this.svg_left = 0; // Return to the default position this.svg_top = 0; sessionStorage['svg_left'] = 0;
      sessionStorage['svg_top'] = 0;
    },
    sizeExpend() {
      this.changeSize("expend"); // Zoom in 0.1},sizeShrink() {
      this.changeSize("shrink"); // Artboard zoom out 0.1},Copy the code

Since it was vuEX control, svgSize was changed in mutation

CHANGE_SIZE: (state, action) => {
      switch (action) {
        case 'init':
          state.svgSize = 1
          break
        case 'expend': state. SvgSize + = 0.1break
        case 'shrink': state. SvgSize - = 0.1break
        default: state.svgSize = state.svgSize
      }
      sessionStorage['svgScale'] = state.svgSize
    },
Copy the code

So far, we have completed the function of move and scale the coordinates of graph, there is an important problem, below is our at the time of operation coordinate behavior, get only the coordinates in the component, it will lead to all the results are misplaced, we need to calculate, take back without zooming displacement when the real coordinates.

Take dragging a node as an example

PaneDragEnd (e) {// node drag end this.dragFrame = {dragFrame:false, posX: 0, posY: 0 }; / / close the modal dialog const x = / / x axis coordinates need to minus the x axis displacement, divided by put half width shrinkage rate minus the modal dialog (e.x - this. InitPos. Left - (sessionStorage ["svg_left"] || 0)) / this.svgScale - 90; // const y = // the y coordinate should be subtracted from the y displacement, divided by the scaling ratio minus half of the modal box height (e.y - this.initpos.top - (sessionStorage["svg_top"] || 0)) / this.svgScale -
        15;
      let params = {
        model_id: sessionStorage["newGraph"],
        id: this.DataAll.nodes[this.choice.index].id,
        pos_x: x,
        pos_y: y
      };
      this.moveNode(params);
    },
Copy the code

For all coordinates, you subtract the offset from the horizontal and vertical coordinates and divide by the scale to get the original scale. The code will not be repeated.

Full screen

Take Chrome for example. Different browsers have different apis for element scaling

    fullScreen() {
      if (this.changeScreen === "Full") {
        this.changeScreen = "Closed";
        let root = document.getElementById("svgContent");
        root.webkitRequestFullScreen();
      } else {
        this.changeScreen = "Full"; document.webkitExitFullscreen(); }}Copy the code

Document.getelementbyid (‘svgContent’).webkitRequestFullScreen() puts the element in full screen. Document. WebkitExitFullScreen exit full-screen ().

Twelve, rubber band box

The idea is to drag a DIV modal box to get the upper left and lower right coordinates, and change the selection state of the nodes in the two coordinates.

    <div :class="choice.paneNode.indexOf(item.id) !== -1 ? 'pane-node-content selected' : 'pane-node-content'"< span style = "box-sizing: border-box; color: RGB (74, 74, 74); font-size: 14px! Important; word-wrap: break-word! Important;"Copy the code

The selected state is the state of the component, so it is placed in the component control, and the vuex box is not used. You only need to push the ID of the selected element into paneNode.

SelAreaStart (e) {// box select node to start calling this.currentevent = on mouseDown"sel_area_ing";
      const x =
        (e.x - this.initPos.left - (sessionStorage["svg_left"] || 0)) /
        this.svgScale;
      const y =
        (e.y - this.initPos.top - (sessionStorage["svg_top"] || 0)) /
        this.svgScale;
      this.simulate_sel_area = {
        left: x,
        top: y,
        width: 0,
        height: 0
      };
    },
    setSelAreaPostion(e) {// box select node ing const x = (e.x - this.initpos. left - (sessionStorage["svg_left"] || 0)) /
        this.svgScale;
      const y =
        (e.y - this.initPos.top - (sessionStorage["svg_top"] || 0)) / this.svgScale; const width = x - this.simulate_sel_area.left; const height = y - this.simulate_sel_area.top; this.simulate_sel_area.width = width; this.simulate_sel_area.height = height; }, getSelNodes(postions) {const {left, top, width, height} = postions; this.choice.paneNode.length = 0; this.DataAll.nodes.forEach(item => {if( item.pos_x > left && item.pos_x < left + width && item.pos_y > top && item.pos_y < top + height ) { this.choice.paneNode.push(item.id); }}); console.log("The node currently selected is, this.choice.paneNode);
    },
Copy the code

This. simulate_sel_area placement box Select the starting coordinates and height and width of the modal box, which can be passed to the component.

Xiii. Event arrangement

Up to now, our project is flooded with a large number of events. Here, we can control the event behavior through currentEvent, trigger corresponding events through listening, and distribute events.

/** * Event dispatcher */ dragIng(e) {// Event dispatcher executes a series of events based on currentEvent switch (this.currentevent) {case 'dragPane':
          if (e.timeStamp - this.timeStamp > 200) {
            this.currentEvent = "PaneDraging"; // confirm drag node};break;
        case 'PaneDraging': this.setDragFramePosition(e); // Trigger node dragbreak;
        case 'dragLink': this.setDragLinkPostion(e); // Trigger a wire dragbreak;
        case 'sel_area_ing': this.setSelAreaPostion(e); // Trigger box selectionbreak;
        case 'move_graph':
          this.graphMoveIng(e);
          break;
        default: () => { }
      }
    }
Copy the code

Reviewing all the content, it took three weeks to complete the realization of the model visualization requirements and the separation of components, hoping to give superficial help to colleagues who need it. All the code is not the best practice, so I just want to throw bricks and draw tips.

Specific code can go to dead simple view click jump: https://github.com/murongqimiao/DAGBoard.

Or go to Zhanglizhong.cn to see the DEMO