“This article has participated in the call for good writing activities, click to view: the back end, the big front end double track submission, 20,000 yuan prize pool waiting for you to challenge!”

Introduction to the

Mind mapping is a common and effective tool for expressing divergent thinking. There are many tools available on the market for mind mapping, both free and paid, as well as JavaScript libraries such as jsMind and KityMinder that can be used for quick implementation.

This article provides a complete overview of how to implement a simple mind map from scratch, with a preview of the final result: wanglin2.github. IO /mind-map/.

Technology selection

There are two options for drawing this kind of graph class: SVG and Canvas, because mind mapping is mainly the connection between nodes and lines, it is easier to operate using SVG that is close to HTML. After trying SVGJS and SNAP in SVG class library, some requirements cannot be found in SNAP, so the author finally chooses SVGJS.

In order to be usable across frameworks, the body of the mind map was developed and distributed as a separate NPM package, organized by classes, and the sample pages were developed using the Vue2.x family bucket.

The overall train of thought

The author of the original idea is to write a renderer, based on the input of the mind map data, rendering into SVG node, calculating the position of each node, and then show to the canvas, and finally to the node can even launch, next to the operation of the mind map is only needs to maintain the data and data changes the empty canvas, and then to apply colours to a drawing, the data driven thoughts is very simple, In the initial development of don’t have any problem, things are going well, because analog data wrote four or five nodes, but then when I put the number of nodes increases to dozens of time, found that cold, too card, click on the node activation expand or shrink when a second or so have a reaction, and just a demo will not acceptable.

Card on the one hand because of the compute nodes location, each layout structure at least need to traverse the node tree three times, plus some calculation logic, will be more time consuming, because rendering node content on the other hand, because in addition to the text, a mind mapping node to support information such as pictures, ICONS, labels, SVG, unlike HTML automatically according to the current layout to layout for you, As a result, each information node needs to be manually calculated, which is a time-consuming operation, and since SVG elements are dom nodes, there are many and frequent operations, which can cause problems.

We found the cause of Caton. How do we fix it? One way is to use canvas instead of SVG, but the author has already written a lot of code when he found this problem, and it is unavoidable even when traversing the canvas tree. Therefore, the author finally adopted the method of rendering according to needs instead of completely re-rendering every time, for example, when clicking the node to activate the node, There is no need to re-render other nodes, just re-render the clicked node. For example, when a node is shrunk or expanded, the position of other nodes only needs to change, and the node content does not need to be re-rendered, so you just need to recalculate the position of other nodes and move them over. The extra benefit is that they can also be moved through animation, and other related operations are the same. Try to update only the necessary nodes and perform the necessary operations. Although there will still be some lag after transformation, it is much better than before.

The data structure

Mind mapping is can be seen as a tree, I call it a render tree, so the basic structure is a tree structure, each node save node itself information, combined with the child nodes of information, in particular, probably need to include the node of various content (text, images, ICONS and other fixed format), node expansion state, child nodes, and so on, It also includes the node’s private style, which overrides the theme’s default style, so that each node can be personalized:

{
  "data": {
    "text": "Root node"."expand": true."color": "#fff".// ...
    "children": []}Copy the code

For details, please refer to node structure.

The render tree is not enough, we need to define a node class. When traversing the render tree, each data node will create an instance of the node, which will hold the state of the node, perform render, calculate width and height, bind events, and other related operations:

/ / the node class
class Node {
  constructor(opt = {}) {
    this.nodeData = opt.data// The actual data of the node is the node of the rendering tree mentioned above
    this.isRoot =  opt.isRoot// Is the root node
    this.layerIndex = opt.layerIndex// Node level
    this.width = 0/ / the node wide
    this.height = 0/ / the node is high
    this.left = opt.left || 0// left
    this.top = opt.top || 0// top
    this.parent = opt.parent || null/ / the parent node
    this.children = []/ / child nodes
    // ...
  }
  
  // ...
}
Copy the code

Because a node may contain text, images, and other information, so we use a container g elements as nodes, and text and then creates a text node, need a border would be to create a the rect node, the node of the final size is the size of the text node plus padding, such as we want to render a with borders only text nodes:

import {
    G,
    Rect,
    Text
} from '@svgdotjs/svg.js'
class Node {
  constructor(opt = {}) {
    // ...
    this.group = new G()// Node container
    this.getSize()
    this.render()
  }
  // Compute node width and height
  getSize() {
    let textData = this.createTextNode()
    this.width = textData.width + 20// The left and right inner margins are 10
    this.height = textData.height + 10// The upper and lower inner margins are 5
  }
  // Create a text node
  createTextNode() {
    let node = new Text().text(this.nodeData.data.text)
    let { width, height } = node.bbox()// Get the width and height of the text node
    return {
      node,
      width,
      height
    }
  }
  // Render the node
  render() {
    let textData = this.createTextNode()
    textData.node.x(10).y(5)// The size of the text node relative to the container's offset inner margin
    // Create a rectangle for the border
    this.group.rect(this.width, this.height).x(0).y(0)
    // Add the text node to the node container
    this.group.add(textData.node)
    // Position the node on the canvas
    this.group.translate(this.left, this.top)
    // Add the container to the canvas
    this.draw.add(this.group)
  }
}
Copy the code

If also need to apply colours to a drawing pictures, you need to create an image nodes, the nodes of the images is high, total height will need to be added in the overall width of the node is the pictures and words the wide size, the position of the text node calculation also need according to the node and the width of the text nodes to calculate the total width, the need to render the same is true for other types of information, in a word, The position of all the nodes needs to be calculated by itself, which is a bit tedious.

See node.js for the complete code of the Node class.

Logical structure diagram

Mind maps have many structures. we will look at the basic logical structure diagram for layout computation. the others will be introduced in the next article.

The logical structure diagram is shown in the figure above, with the child node to the right of the parent node, and then the parent node as a whole is vertically centered relative to the child node.

Node localization

This idea comes from something I saw online. First we position the root node in the middle of the canvas and then iterate over the children, so that the left of the children is the left of the root node + the width of the root node + the spacing between them, as shown below:

Then iterate over the children of each of the children (recursive traversal) in the same way to calculate left, so that the left value of all nodes is calculated after the traversal is complete.

class Render {
  // Walk through the render tree for the first time
  walk(this.renderer.renderTree, null.(cur, parent, isRoot, layerIndex) = > {
    // order traversal first
    // Create a node instance
    let newNode = new Node({
      data: cur,// Node data
      layerIndex/ / hierarchy
    })
    // The node instance is associated with node data
    cur._node = newNode
    / / the root node
    if (isRoot) {
      this.root = newNode
      // Positioned in the center of the canvas
      newNode.left = (this.mindMap.width - node.width) / 2
      newNode.top = (this.mindMap.height - node.height) / 2
    } else {// Non-root node
      // Collect from each other
      newNode.parent = parent._node
      parent._node.addChildren(newNode)
      // Locate to the right of the parent node
      newNode.left = parent._node.left + parent._node.width + marginX
    }
  }, null.true.0)}Copy the code

The next step is top. First, only the top of the root node is determined at the beginning, so how can the child node be positioned according to the top of the parent node? If we know the total height of all the child nodes, then the top of the first child node is determined:

firstChildNode.top = (node.top + node.height / 2) - childrenAreaHeight / 2
Copy the code

As shown in the figure:

The top of the first child node is determined, and the other nodes simply add up to the top of the previous node.

So how do you compute the childrenAreaHeight? First traversal to a Node for the first time, we will give it to create a Node instance, and then trigger the calculation of the Node size, so only when all child nodes after traversing the back we can calculate the total height, it can apparently in the sequence traversal time to calculate, but top at the next only to compute the Node of traversing the render tree, Why not calculate the top of a node’s childrenAreaHeight immediately after calculating the node’s childrenAreaHeight? The reason is very simple, the current node top has not been determined, how to determine the location of the child node?

// First traversal
walk(this.renderer.renderTree, null.(cur, parent, isRoot, layerIndex) = > {
  // order traversal first
  // ...
}, (cur, parent, isRoot, layerIndex) = > {
  // after the sequence traversal
  // Calculate the sum of heights occupied by all children of the node, including margin between nodes and the distance before and after the whole node
  let len = cur._node.children
  cur._node.childrenAreaHeight = cur._node.children.reduce((h, node) = > {
    return h + node.height
  }, 0) + (len + 1) * marginY
}, true.0)
Copy the code

To summarize, in the first iteration of the render tree, we create the Node instance in the first iteration, then compute the left of the Node, and then compute the total height of all the children of each Node in the second iteration.

Next, start the second round of traversal, this round of traversal can calculate the top of all nodes, because the node tree has been successfully created at this point, so you can directly traverse the node tree without traversing the rendering tree:

// The second pass
walk(this.root, null.(node, parent, isRoot, layerIndex) = > {
  if (node.children && node.children.length > 0) {
    // The top value of the first child is equal to half of the sum of the top value of the center of the node - the height of the children
    let top = node.top + node.height / 2 - node.childrenAreaHeight / 2
    let totalTop = top + marginY// node.childrenAreaHeight is the total distance before and after the child
    node.children.forEach((cur) = > {
      cur.top = totalTop
      totalTop += cur.height + marginY// Add the spacing marginY and the height of the node to the top of the previous node})}},null.true)
Copy the code

That’s not the end of the story, see the picture below:

Can see for each node, the position is correct, but, on the whole is wrong, because of the overlap, the reason is very simple, because the child nodes of the secondary node 1 】 【 too much, total height of child nodes has exceeded the node itself is high, because of 【 】 the secondary node location is calculated on the basis of the total height of the secondary node 】 【, The solution is simple. Take another round of traversal, and when you find that the total height of the children of a node is greater than its own height, move the nodes before and after the node out, as shown in the figure above. Assume that the height of the children is 100px higher than the height of the node itself. Then we move [secondary node 2] down 50px, and up 50px if there are other nodes on it. Note that this adjustment will bubble all the way to the parent node. For example:

1-2 】 【 child nodes of the child element is bigger than the total height of its own, so 1-1 】 【 child nodes need to move up, it is not enough, say there is a child of the node 0 2 】 【 node, then they might also want to overlap, and at the bottom of the [child node 2-1-1] and [child node 1-2-3 】 obviously get too close, Therefore, after the sibling node of [child node 1-1] is adjusted, the sibling node of the parent node [secondary node 1] also needs to be adjusted, with the upper node moving up and the lower node moving down until the root node:

// The third pass
walk(this.root, null.(node, parent, isRoot, layerIndex) = > {
  // Determine whether the sum of the height of the child nodes (excluding the margin before and after the child nodes) is greater than the node itself
  let difference = node.childrenAreaHeight - marginY * 2 - node.height
  // If the value is greater than, the sibling nodes need to be adjusted
  if (difference > 0) {
    this.updateBrothers(node, difference / 2)}},null.true)
Copy the code

UpdateBrothers is used to recursively move sibling nodes up:

updateBrothers(node, addHeight) {
  if (node.parent) {
    let childrenList = node.parent.children
    // Find out which node you are in
    let index = childrenList.findIndex((item) = > {
      return item === node
    })
    childrenList.forEach((item, _index) = > {
      if (item === node) {
        return
      }
      let _offset = 0
      // The upper node moves up
      if (_index < index) {
        _offset = -addHeight
      } else if (_index > index) { // The bottom node moves down
        _offset = addHeight
      }
      // Move the node
      item.top += _offset
      // If the node itself moves, all of its children need to be moved simultaneously
      if (item.children && item.children.length) {
        this.updateChildren(item.children, 'top', _offset)
      }
    })
    // Traverse up to move the parent's siblings
    this.updateBrothers(node.parent, addHeight)
  }
}
Copy the code
// Update the location of all children of the node
updateChildren(children, prop, offset) {
  children.forEach((item) = > {
    item[prop] += offset
    if (item.children && item.children.length) {
      this.updateChildren(item.children, prop, offset)
    }
  })
}
Copy the code

At this point, the layout calculation of the logical structure diagram is complete, of course, there is a small problem:

Strictly speaking, a node may no longer be centered with respect to all of its children, but with respect to all of its descendants. This is not a problem. If you are obsessively obsessed, you can think about how to optimize it (and then tell me in private).

The node connection

Node location is good, and then on to the attachment, connect the node and all its child nodes, attachment style has a lot of, can use the straight line, can also use the curve, straight line is simple, because of all the nodes of the left, top, width, height already knows, so cable turning point coordinates can be calculated easily:

Let’s focus on the curve connections. As shown in the previous picture, the line of the root node is different from that of other nodes. The line of the root node to its children is as follows:

This simple curve can use a quadratic Bessel curve with the starting coordinates being the intermediate points of the root node:

let x1 = root.left + root.width / 2
let y1 = root.top + root.height / 2
Copy the code

The end point coordinates are the left middle of each child node:

let x2 = node.left
let y2 = node.top + node.height / 2
Copy the code

As long as a control point is determined, the specific point can be adjusted by itself and a pleasing position can be found. The author finally chooses:

let cx = x1 + (x2 - x1) * 0.2
let cy = y1 + (y2 - y1) * 0.8)Copy the code

Now look at the connections of the lower nodes:

It can be seen that there are two sections of bending, so it is necessary to use three Bezier curves. Similarly, two appropriate control points should be selected by themselves. The author’s choice is shown as follows, and x of the two control points is in the middle of the starting point and ending point:

  let cx1 = x1 + (x2 - x1) / 2
  let cy1 = y1
  let cx2 = cx1
  let cy2 = y2
Copy the code

Add a render line method to the Node class:

class Node {
  // Render a line from a node to its children
  renderLine() {
    let { layerIndex, isRoot, top, left, width, height } = this
    this.children.forEach((item, index) = > {
      // The root node starts from the middle of the node, and everything else is on the right
      let x1 = layerIndex === 0 ? left + width / 2 : left + width
      let y1 = top + height / 2
      let x2 = item.left
      let y2 = item.top + item.height / 2
      let path = ' '
      if (isRoot) {
        path = quadraticCurvePath(x1, y1, x2, y2)
      } else {
        path = cubicBezierPath(x1, y1, x2, y2)
      }
      // Draw an SVG path to the canvas
      this.draw.path().plot(path)
    })
  }
}

// The connection between the root node and its children
const quadraticCurvePath = (x1, y1, x2, y2) = > {
  // Control points of quadratic Bezier curves
  let cx = x1 + (x2 - x1) * 0.2
  let cy = y1 + (y2 - y1) * 0.8
  return `M ${x1}.${y1} Q ${cx}.${cy} ${x2}.${y2}`
}

// Connect other nodes to their children
const cubicBezierPath = (x1, y1, x2, y2) = > {
  // Two control points of the cubic Bezier curve
  let cx1 = x1 + (x2 - x1) / 2
  let cy1 = y1
  let cx2 = cx1
  let cy2 = y2
  return `M ${x1}.${y1} C ${cx1}.${cy1} ${cx2}.${cy2} ${x2}.${y2}`
}
Copy the code

Nodes to activate

Click on a node relative to activate it, in order to be a little feedback, so you need to add one style of activation to it, are usually add a border to it, but the author is not satisfied with this, the author thinks that the style of all nodes, activated can change, so that we can better fusion to the theme, namely node all styles have two states, normal state and the activated state, The disadvantage is that activation and deactivation of many operations, will bring a bit of lag.

The implementation can listen for the click event of the node, and then set the activation flag of the node. Because multiple active nodes can exist at the same time, an array is used to hold all the active nodes.

class Node {
  bindEvent() {
    this.group.on('click'.(e) = > {
      e.stopPropagation()
      // Return to the active state
      if (this.nodeData.data.isActive) {
        return
      }
      // Clear the activation status of the active node
      this.renderer.clearActive()
      // Execute the command to activate click the active state of the node
      this.mindMap.execCommand('SET_NODE_ACTIVE'.this.true)
      // Add to the active list
      this.renderer.addActiveNode(this)}}}Copy the code

The SET_NODE_ACTIVE command will re-render the node, so we can apply different styles based on the active state of the node in the render logic, as described in the styles and themes section below.

Copy editor

Text editor is simpler, monitored nodes container double-click events, then capture text node wide high and location, and then cover the size of a same editor layer above, editing the enter key monitoring, hidden edit layer, and then modify the node data to render the node, if the node size change will update the position of the other nodes.

class Node {
  // Bind events
  bindEvent() {
    this.group.on('dblclick'.(e) = > {
      e.stopPropagation()
      this.showEditTextBox()
    })
  }
  
  // Displays the text editing layer
  showEditTextBox() {
    // Get the position and size of the text node
    let rect = this._textData.node.node.getBoundingClientRect()
    // Create a text edit layer node without creating one
    if (!this.textEditNode) {
      this.textEditNode = document.createElement('div')
      this.textEditNode.style.cssText = ` position:fixed; box-sizing: border-box; background-color:#fff; Box-shadow: 0 0 20px rgba(0,0,0,.5); padding: 3px 5px; margin-left: -5px; margin-top: -3px; outline: none; `
      // Enable edit mode
      this.textEditNode.setAttribute('contenteditable'.true)
      document.body.appendChild(this.textEditNode)
    }
    // Replace the literal newline with a newline element
    this.textEditNode.innerHTML = this.nodeData.data.text.split(/\n/img).join('<br>')
    // Locate and display text edit boxes
    this.textEditNode.style.minWidth = rect.width + 10 + 'px'
    this.textEditNode.style.minHeight = rect.height + 6 + 'px'
    this.textEditNode.style.left = rect.left + 'px'
    this.textEditNode.style.top = rect.top + 'px'
    this.textEditNode.style.display = 'block'}}Copy the code

One small detail is that when the node supports personalization, it is necessary to set the node text style, such as font size, line-height and so on, to the editing node, so as to maintain consistency, although it is a layer on top, but it does not feel too obtruse.

class Node {
  // Register shortcut key
  registerCommand() {
    // Register the return shortcut key
    this.mindMap.keyCommand.addShortcut('Enter'.() = > {
      this.hideEditTextBox()
    })
  }

  // Close the text edit box
  hideEditTextBox() {
    // Iterate over the list of active nodes and modify their text information
    this.renderer.activeNodeList.forEach((node) = > {
      // This method removes the tags in the HTML string and replaces the br tag with \n
      let str = getStrWithBrFromHtml(this.textEditNode.innerHTML)
      // Execute the command to set the node text
      this.mindMap.execCommand('SET_NODE_TEXT'.this, str)
      // Update other nodes
      this.mindMap.render()
    })
    // Hide the text editing layer
    this.textEditNode.style.display = 'none'
    this.textEditNode.innerHTML = ' '}}Copy the code

The other two concepts mentioned above, the registration shortcut and the execution command, will be covered in a later section. The complete code of the node editing class is textedit.js.

Unfolding and retracting

Sometimes there are too many nodes, we do not need to display all of them, so we can display only the required nodes by expanding and closing. First, we need to render an expand and close button for the node with child nodes, and then bind the click event to switch the expansion and contraction state of the node:

class Node {
  renderExpandBtn() {
    // No child node or root node is returned directly
    if (!this.nodeData.children || this.nodeData.children.length <= 0 || this.isRoot) {
      return
    }
    // Button container
    this._expandBtn = new G()
    let iconSvg
    // Determine which icon to render according to the expansion state of the node. Oepn and close are SVG strings
    if (this.nodeData.data.expand === false) {
      iconSvg = btnsSvg.open
    } else {
      iconSvg = btnsSvg.close
    }
    let node = SVG(iconSvg).size(this.expandBtnSize, this.expandBtnSize)
    Render a transparent circle in response to mouse events since ICONS are path elements that are hard to click on
    let fillNode = new Circle().size(this.expandBtnSize)
    // Add to the container
    this._expandBtn.add(fillNode).add(node)
    // Bind the click event
    this._expandBtn.on('click'.(e) = > {
      e.stopPropagation()
      // Execute the command to expand the shrink
      this.mindMap.execCommand('SET_NODE_EXPAND'.this,!this.nodeData.data.expand)
    })
    // Set the display position of the button to the right vertical center of the node
    this._expandBtn.translate(width, height / 2)
    // Add to the container of the node
    this.group.add(this._expandBtn)
  }
}
Copy the code

The SET_NODE_EXPAND command sets the expansion and collapse state of a node, and renders or deletes all descendant nodes to expand or expand, and recalculates and moves the positions of all other nodes. In addition, the code used to calculate the position of the tree also needs to add expansion and contraction judgment:

// First traversal
walk(this.renderer.renderTree, null.(cur, parent, isRoot, layerIndex) = > {
  // ...
}, (cur, parent, isRoot, layerIndex) = > {
  // after the sequence traversal
  if (cur.data.expand) {// Expand state
    cur._node.childrenAreaHeight = cur._node.children.reduce((h, node) = > {
      return h + node.height
    }, 0) + (len + 1) * marginY
  } else {// If the object is folded, its childrenAreaHeight should obviously be 0
    cur._node.childrenAreaHeight = 0}},true.0)
Copy the code
// The second pass
walk(this.root, null.(node, parent, isRoot, layerIndex) = > {
  // Only children of the expanded node are counted
  if (node.nodeData.data.expand && node.children && node.children.length > 0) {
    let top = node.top + node.height / 2 - node.childrenAreaHeight / 2
    // ...}},null.true)
Copy the code
// The third pass
walk(this.root, null.(node, parent, isRoot, layerIndex) = > {
  // It is no longer necessary to determine the height of child nodes
  if(! node.nodeData.data.expand) {return;
  }
  let difference = node.childrenAreaHeight - marginY * 2 - node.height
  // ...
  }, null.true)
Copy the code

At this point, a basic working mind map is complete.

To add a small detail, the mobile node mentioned above, the code is actually very simple:

let t = this.group.transform()
this.group.animate(300).translate(this.left - t.translateX, this.top - t.translateY)
Copy the code

Since Translate transforms from the previous one, you need to get the current transform and subtract it to get the increment. For animation, using SVGJS you just need to perform animate in passing.

The command

The previous code has involved several commands, we will change the node state through the command call, each call will save a copy of the current node data, with back and forth and forward.

Commands are similar to issuing subscribers, registering commands first and then triggering their execution:

class Command {
  constructor() {
    // Save the command
    this.commands = {}
    // Save the historical copy
    this.history = []
    // Historical location of the current location
    this.activeHistoryIndex = 0
  }

  // Add a command
  add(name, fn) {
    if (this.commands[name]) {
      this.commands[name].push(fn)
    } else[
      this.commands[name] = [fn]
    ]
  }

  // Execute the command
  exec(name, ... args) {
    if (this.commands[name]) {
      this.commands[name].forEach((fn) = >{ fn(... args) })// Save a copy of the current data to the history list
      this.addHistory()
    }
  }

  // Save a copy of the current data to the history list
  addHistory() {
    // A deep copy of the current data
    let data = this.getCopyData()
    this.history.push(data)
    this.activeHistoryIndex = this.history.length - 1}}Copy the code

For example, the previous SET_NODE_ACTIVE command would register:

class Render {
  registerCommand() {
    this.mindMap.command.add('SET_NODE_ACTIVE'.this.setNodeActive)
  }

  // Set whether the node is activated
  setNodeActive(node, active) {
    // Set the node activation status
    this.setNodeData(node, {
      isActive: active
    })
    // Re-render the node content
    node.renderNode()
  }
}
Copy the code

Back and forward

The command in the previous section already saved all the data after the operation, so all you need to do isto move back and forward the activeHistoryIndex pointer, then get the historical data at this location, make a copy to replace the current render tree, and finally trigger the re-render, which will do the whole re-render. So it’s a little bit stuck.

class Command {
  / / back to back
  back(step = 1) {
    if (this.activeHistoryIndex - step >= 0) {
      this.activeHistoryIndex -= step
      return simpleDeepClone(this.history[this.activeHistoryIndex]); }}/ / to go forward
  forward(step = 1) {
    let len = this.history.length
    if (this.activeHistoryIndex + step <= len - 1) {
      this.activeHistoryIndex += step
      return simpleDeepClone(this.history[this.activeHistoryIndex]); }}}Copy the code
class Render {
  / / back to back
  back(step) {
    let data = this.mindMap.command.back(step)
    if (data) {
      // Replace the current render tree
      this.renderTree = data
      this.mindMap.reRender()
    }
  }

  / / to go forward
  forward(step) {
    let data = this.mindMap.command.forward(step)
    if (data) {
      this.renderTree = data
      this.mindMap.reRender()
    }
  }
}
Copy the code

Styles and Themes

The theme includes all the styles of the node, such as colors, padding, fonts, borders, margins, etc., as well as the line thickness, color, background color or picture of the canvas, etc.

The structure of a topic is roughly as follows:

export default {
    // Inner margin of the node
    paddingX: 15.paddingY: 5.// The thickness of the line
    lineWidth: 1.// The color of the line
    lineColor: '# 549688'.// Background color
    backgroundColor: '#fafafa'.// ...
    // The root node style
    root: {
        fillColor: '# 549688'.fontFamily: 'Microsoft YaHei'.color: '#fff'.// ...
        active: {
            borderColor: 'rgb(57, 80, 96)'.borderWidth: 3.borderDasharray: 'none'.// ...}},// Secondary node style
    second: {
        marginX: 100.marginY: 40.fillColor: '#fff'.// ...
        active: {
            // ...}},// Level 3 and below node styles
    node: {
        marginX: 50.marginY: 0.fillColor: 'transparent'.// ...
        active: {
            // ...}}}Copy the code

The outermost layer is non-node style. For nodes, it is also divided into three types, namely root node, secondary node and other nodes. Inside each node, it is divided into normal style and activated style.

Each information element of the node is styled, such as the text and border elements mentioned earlier:

class Node {
  // Create a text node
  createTextNode() {
    let node = new Text().text(this.nodeData.data.text)
    // Style the text node
    this.style.text(node)
    let { width, height } = node.bbox()
    return {
      node: g,
      width,
      height
    }
  }
  
  // Render the node
  render() {
    let textData = this.createTextNode()
    textData.node.translate(10.5)
    // Apply a style to the border node
    this.style.rect(this.group.rect(this.width, this.height).x(0).y(0))
    // ...}}Copy the code

Style is an instance of the style class style, which is instantiated on each node (it’s not necessary, it may change later) to style various elements, and it selects the appropriate style based on the node’s type and active state:

class Style {
  // Style the text node
  text(node) {
    node.fill({
      color: this.merge('color')
    }).css({
      'font-family': this.merge('fontFamily'),
      'font-size': this.merge('fontSize'),
      'font-weight': this.merge('fontWeight'),
      'font-style': this.merge('fontStyle'),
      'text-decoration': this.merge('textDecoration')}}}Copy the code

Merge is a way to determine which style to use:

class Style {
  // Root is not the root node, but represents the non-node style
  merge(prop, root) {
    // The style of the third level and below nodes
    let defaultConfig = this.themeConfig.node
    if (root) {// Non-node style
      defaultConfig = this.themeConfig
    } else if (this.ctx.layerIndex === 0) {/ / the root node
      defaultConfig = this.themeConfig.root
    } else if (this.ctx.layerIndex === 1) {// Secondary node
      defaultConfig = this.themeConfig.second
    }
    // Active state
    if (this.ctx.nodeData.data.isActive) {
      // If the node has a separate style set, the node's style is preferred
      if (this.ctx.nodeData.data.activeStyle && this.ctx.nodeData.data.activeStyle[prop] ! = =undefined) {
        return this.ctx.nodeData.data.activeStyle[prop];
      } else if (defaultConfig.active && defaultConfig.active[prop]) {// Otherwise use the theme default
        return defaultConfig.active[prop]
      }
    }
    // The style of the node itself is preferred
    return this.ctx.nodeData.data[prop] ! = =undefined ? this.ctx.nodeData.data[prop] : defaultConfig[prop]
  }
}
Copy the code

We will first determine whether a node itself has this style, if so, then use its own first, so as to achieve the ability of each node can be personalized.

Style editing is to display and modify all these configurable styles through visual controls. In practice, you can listen to the node activation event, then open the style editing panel, first display the current style, and then modify a style through the corresponding command set to the current active node:

You can see the distinction between normal and selected, this part of the code is very simple, can refer to: style.vue.

In addition to node style editing, the style of non-nodes is also modified in the same way. First, the current theme configuration is obtained, and then the output is displayed. Users can set the theme through the corresponding method after modification:

The code for this section is in Basestyle.vue.

shortcuts

Shortcut keys are simply to listen to press a specific key to perform a specific operation, in fact, the implementation is also a publish and subscribe mode, first register the shortcut key, and then listen to the key to execute the corresponding method.

First of all, keys are numbers and not easy to remember, so we need to maintain a key-to-key mapping table, like the following:

const map = {
    'Backspace': 8.'Tab': 9.'Enter': 13.// ...
}
Copy the code

The full map can be found here: keymap.js.

There are three types of shortcut keys: single key, combination key, multiple “or” relationship key, can use an object to hold the key value and callback:

{
  'Enter': [() = >{}].'Control+Enter': [].'Del|Backspace': []}Copy the code

Then add a method to register a shortcut key:

class KeyCommand {
  // Register shortcut key
  addShortcut(key, fn) {
    // Convert the or shortcut key into a single key for processing
    key.split(/\s*\|\s*/).forEach((item) = > {
      if (this.shortcutMap[item]) {
        this.shortcutMap[item].push(fn)
      } else {
        this.shortcutMap[item] = [fn]
      }
    })
  }
}
Copy the code

For example, register a shortcut to delete a node:

this.mindMap.keyCommand.addShortcut('Del|Backspace'.() = > {
  this.removeNode()
})
Copy the code

With the registry, of course, you need to listen for key events:

class KeyCommand {
  bindEvent() {
    window.addEventListener('keydown'.(e) = > {
      // Run through all the registered keys to see if there is a match, and execute its callback queue if there is a match
      Object.keys(this.shortcutMap).forEach((key) = > {
        if (this.checkKey(e, key)) {
          e.stopPropagation()
          e.preventDefault()
          this.shortcutMap[key].forEach((fn) = > {
            fn()
          })
        }
      })
    })
  }
}
Copy the code

The checkKey method is used to check whether the registered key value matches the current key press. The checkKey is usually a combination of CTRL, Alt, Shift and other keys. If these three keys are pressed, the corresponding field in the event object E will be set to true. It then uses the keyCode field to determine if a key combination is matched.

class KeyCommand {
    checkKey(e, key) {
        // Get the array of keys in the event object
        let o = this.getOriginEventCodeArr(e)
        // An array of registered keys,
        let k = this.getKeyCodeArr(key)
        // Check whether the two arrays are the same. If they are, the match is successful
        if (this.isSame(o, k)) {
            return true
        }
        return false}}Copy the code

The getOriginEventCodeArr method returns an array of pressed keys from the event object:

getOriginEventCodeArr(e) {
    let arr = []
    // Press the control key
    if (e.ctrlKey || e.metaKey) {
        arr.push(keyMap['Control'])}// Press Alt key
    if (e.altKey) {
        arr.push(keyMap['Alt'])}// Press shift key
    if (e.shiftKey) {
        arr.push(keyMap['Shift'])}// Other keys are pressed at the same time
    if(! arr.includes(e.keyCode)) { arr.push(e.keyCode) }return arr
}
Copy the code

GetKeyCodeArr (); getKeyCodeArr (); getKeyCodeArr (); getKeyCodeArr (); getKeyCodeArr (); getKeyCodeArr ();

getKeyCodeArr(key) {
    let keyArr = key.split(/\s*\+\s*/)
    let arr = []
    keyArr.forEach((item) = > {
        arr.push(keyMap[item])
    })
    return arr
}
Copy the code

Drag to zoom in and out

First, take a look at the basic structure:

/ / the canvas
this.svg = SVG().addTo(this.el).size(this.width, this.height)
// The actual container of the mind map node
this.draw = this.svg.group()
Copy the code

So dragging, zooming in and out is all about the G element, and applying the relevant transformation to it. To drag, simply listen for the mouse movement event and change the translate attribute of the G element:

class View {
    constructor() {
        // Start offset when the mouse is pressed down
        this.sx = 0
        this.sy = 0
        // The current real-time offset
        this.x = 0
        this.y = 0
        // Drag the view
        this.mindMap.event.on('mousedown'.() = > {
            this.sx = this.x
            this.sy = this.y
        })
        this.mindMap.event.on('drag'.(e, event) = > {
            // event.mousemoveOffset indicates the distance moved after the mouse is pressed
            this.x = this.sx + event.mousemoveOffset.x
            this.y = this.sy + event.mousemoveOffset.y
            this.transform()
        })
    }
    
    // Set the transform
    transform() {
        this.mindMap.draw.transform({
            scale: this.scale,
            origin: 'left center'.translate: [this.x, this.y],
        })
    }
}
Copy the code

Scale = this.scale = this.scale = this.scale = this.scale = this.scale = this.scale = this.scale = this.scale

this.scale = 1

// Zoom in and out
this.mindMap.event.on('mousewheel'.(e, dir) = > {
    / / / / amplification
    if (dir === 'down') {
        this.scale += 0.1
    } else { / / to narrow
        this.scale -= 0.1
    }
    this.transform()
})
Copy the code

Multiple nodes

Multiple nodes is also an indispensable function, such as I want to delete multiple nodes at the same time, or set the same style to multiple nodes, each node operation obviously slow, the mind map are generally available in the market to multi-select according the left mouse button drag, right click and drag to move the canvas, but the author’s personal habits to reverse it.

Multi-selection is actually very simple, the starting point is mouse down, and the real-time position of mouse movement is the end point. Then, if a node is in the rectangular area composed of these two points, it is equivalent to being selected. It should be noted that transformation issues should be considered, such as dragging and zooming in and out, then the left and top of the node also need to be changed:

class Select {
    // Check whether the node is in the selection
    checkInNodes() {
        // Get the current transform information
        let { scaleX, scaleY, translateX, translateY } = this.mindMap.draw.transform()
        let minx = Math.min(this.mouseDownX, this.mouseMoveX)
        let miny = Math.min(this.mouseDownY, this.mouseMoveY)
        let maxx = Math.max(this.mouseDownX, this.mouseMoveX)
        let maxy = Math.max(this.mouseDownY, this.mouseMoveY)
        // Walk through the node tree
        bfsWalk(this.mindMap.renderer.root, (node) = > {
            let { left, top, width, height } = node
            // The node position needs to be changed accordingly
            let right = (left + width) * scaleX + translateX
            let bottom = (top + height) * scaleY + translateY
            left = left * scaleX + translateX
            top = top * scaleY + translateY
            // Check whether the entire area is in the selection rectangle, you can also change to partial overlap to count
            if (
                left >= minx &&
                right <= maxx &&
                top >= miny &&
                bottom <= maxy
            ) {
                // In the selection, activate the node
            } else if (node.nodeData.data.isActive) {
                // No longer in the selection, if the current state is activated, deactivate}}}})Copy the code

Another detail is that when the mouse moves to the edge of the canvas, the G element needs to be moved. For example, if the mouse is already moved to the bottom of the canvas, the G element automatically moves up (of course, the starting position of the mouse also needs to be changed), otherwise the nodes outside the canvas cannot be selected:

See select.js for the complete code.

export

You can export to SVG, images, plain text, Markdown, PDF, JSON, and even other mind map formats. Some of these formats are difficult to implement purely on the front end, so this section only describes how to export to SVG and images.

Export the SVG

Export SVG is very simple, because we use SVG is drawn, so as long as the SVG whole node converts HTML string export is ok, but it’s not directly, because, in fact, mind mapping accounted for only part of the canvas, the remaining white space is useless, and if, after amplification, mind mapping part is beyond the canvas, Then the export is incomplete, so we want to export the content as shown in the shadow below, namely the complete mind map graph, and the original size, regardless of scaling:

In the above section drag, zoom in on 】 【 introduces the mind map all of the nodes are wrapped by a g element, related transformation effect is also applied in this element, our idea is to remove it first zoom in effect, it can get high to its original width, then the canvas is adjusted to the SVG element width is high, Then try to move the G element to the position of the SVG, so that the exported SVG is exactly the original size and complete, and then restore the SVG element to its previous transform and size.

Step by step:

1. Initial status

2. Drag and zoom in

3. Remove its zoom transform

// Get the current transform data
const origTransform = this.mindMap.draw.transform()
// Remove the zooming and zooming effects, just like Translate, so divide by the current zoom to get 1
this.mindMap.draw.scale(1 / origTransform.scaleX, 1 / origTransform.scaleY)
Copy the code

4. Resize the SVG canvas to the actual size of the G element

// Rbox is a wrapper around the getBoundingClientRect method that SVGJS provides to get the transformed position and size information
const rect = this.mindMap.draw.rbox()
this.mindMap.svg.size(rect.wdith, rect.height)
Copy the code

The SVG element becomes the size of the shaded area in the upper left, and you can also see that the G element is invisible because it is outside the current SVG scope.

5. Move the G element to the upper left corner of SVG

const rect = this.mindMap.draw.rbox()
const elRect = this.mindMap.el.getBoundingClientRect()
this.mindMap.draw.translate(-rect.x + elRect.left, -rect.y + elRect.top)
Copy the code

The g element should be fully displayed:

6. Export SVG elements

The complete code is as follows:

class Export {
    // Get the SVG data to export
    getSvgData() {
        const svg = this.mindMap.svg
        const draw = this.mindMap.draw
        // Save the original information
        const origWidth = svg.width()
        const origHeight = svg.height()
        const origTransform = draw.transform()
        const elRect = this.mindMap.el.getBoundingClientRect()
        // Remove the zooming and zooming transformation effect
        draw.scale(1 / origTransform.scaleX, 1 / origTransform.scaleY)
        GetBoundingClientRect getBoundingClientRect getBoundingClientRect getBoundingClientRect
        const rect = draw.rbox()
        // Set SVG to the width and height of the actual content
        svg.size(rect.wdith, rect.height)
        // Move g to match SVG
        draw.translate(-rect.x + elRect.left, -rect.y + elRect.top)
        // Clone the SVG node
        const clone = svg.clone()
        // Restore the original size and transform information
        svg.size(origWidth, origHeight)
        draw.transform(origTransform)
        return {
            node: clone,// Node object
            str: clone.svg()// HTML string}}// Export the SVG file
    svg() {
        let { str } = this.getSvgData()
        // Convert to BLOB data
        let blob = new Blob([str], {
            type: 'image/svg+xml'
        });
        let file = URL.createObjectURL(blob)
        // Trigger the download
        let a = document.createElement('a')
        a.href = file
        a.download = fileName
        a.click()
    }
}
Copy the code

Export PNG

Exporting PNG is on the basis of exporting SVG, we have obtained the content of the EXPORTED SVG in the previous step, so this step is to find a way to convert SVG into PNG. First of all, we know that the IMG tag can directly display SVG files, so we can open SVG through the IMG tag. Then draw the image to canvas and export it to PNG format.

However, there is another problem to be solved before this, that is, if there is an image element in SVG and the image is referenced by external chain (whether homologous or non-homologous), it will not be displayed on canvas. Generally, there are two solutions: One is to remove all image elements from SVG and draw them manually to canvas. The second is to convert the image URL into data: URL format. For simplicity, the author chooses the second method:

class Export {
    async getSvgData() {
		// ...
        // Convert the url of the image to data: URL, otherwise the image will be lost
        let imageList = clone.find('image')
        let task = imageList.map(async (item) => {
            let imgUlr = item.attr('href') || item.attr('xlink:href')
            let imgData = await imgToDataUrl(imgUlr)
            item.attr('href', imgData)
        })
        await Promise.all(task)
        return {
            node: clone,
            str: clone.svg()
        }
    }
}
Copy the code

The imgToDataUrl method also uses canvas to convert images to data: URLS. In this way, the converted SVG content can be displayed normally when drawn to canvas:

class Export {
    / / export PNG
    async png() {
        let { str } = await this.getSvgData()
        // Convert to BLOB data
        let blob = new Blob([str], {
            type: 'image/svg+xml'
        })
        // Convert to the object URL
        let svgUrl = URL.createObjectURL(blob)
        // Draw to canvas and convert to PNG
        let imgDataUrl = await this.svgToPng(svgUrl)
        / / download
        let a = document.createElement('a')
        a.href = file
        a.download = fileName
        a.click()
    }
    
    / / SVG PNG
    svgToPng(svgSrc) {
        return new Promise((resolve, reject) = > {
            const img = new Image()
            // Cross-domain images need this property, otherwise the canvas is polluted and cannot export the image
            img.setAttribute('crossOrigin'.'anonymous')
            img.onload = async() = > {try {
                    let canvas = document.createElement('canvas')
                    canvas.width = img.width + this.exportPadding * 2
                    canvas.height = img.height + this.exportPadding * 2
                    let ctx = canvas.getContext('2d')
                    // Draw the image to the canvas
                    ctx.drawImage(img, 0.0, img.width, img.height, this.exportPadding, this.exportPadding, img.width, img.height)
                    resolve(canvas.toDataURL())
                } catch (error) {
                    reject(error)
                }
            }
            img.onerror = (e) = > {
                reject(e)
            }
            img.src = svgSrc
        })
    }
}
Copy the code

Here the Export is finished, but one detail is omitted above, that is, the drawing of the background. In fact, the background related style is set on the el element of the container before we Export, so we need to set it on SVG or canvas before exporting, otherwise there will be no background in the Export. You can read the relevant code in export-.js.

conclusion

This paper introduces the implementing a web of mind mapping involves some technical points, to be sure, because of the level limits, the realization of the code is rough, and there are some problems on the performance, so are for reference only, the other is because the author for the first time using SVG, so SVG is inevitable mistakes, or have better implementation, welcome to leave a message.

There are also some common functions, such as small window navigation, free themes, etc., interested in their own implementation, the next chapter will mainly introduce the implementation of the other three variants of the structure, please look forward to.