Use vue3 and svg canvas tags to develop a mind map function

Foreword

Definition of SVG

  1. SVG is a language defined in XML for describing two-dimensional vectors and vector or raster graphics. SVG provides three types of graphic objects, vector graphics, images, and text. SVG is an image file format. Its full English name is ScalableVectorGraphics, which means scalable vector graphics.
  2. Vector graphics, also known as object-oriented images or drawing images, are mathematically defined as a series of points connected by lines. Graphic elements in vector files are called objects. Each object is a self-contained entity with properties such as color, shape, outline, size, and screen position.
  3. XML is a set of rules that define semantic markup that divides a document into parts and identifies those parts. It is also a meta-markup language, that is, it defines a syntactic language for defining other domain-specific, semantic, and structured markup languages.

What is mind mapping

  1. Mind map, English is The Mind Map, also known as mind map. It is an effective graphic thinking tool to express divergent thinking. It is simple but very effective and efficient. It is a practical thinking tools.
  2. Mind mapping uses the technique of paying equal attention to pictures and texts to express the relationship between topics at all levels with mutually subordinate and related hierarchical diagrams, and to establish memory links between topic keywords, images, colors, etc.
  3. Mind mapping makes full use of the functions of the left and right brains, and uses the laws of memory, reading, and thinking to help people develop a balanced development between science and art, logic and imagination, thereby unlocking the unlimited potential of the human brain. Mind mapping therefore has the powerful function of human thinking.

image.png

Initial thoughts

Because I used D3.js to complete the function of an organizational chart in the project I developed, I thought it was quite interesting, and then I just happened to see someone using Xmind on the Internet. > I drew many beautiful mind maps, so I wanted to imitate the UI of Xmind and use D3.js to make a simple version of the mind map, which can just deepen my own Knowledge of svg and D3.js.

Development process

If you want to develop a most basic version of a mind map, you must first understand how to define the data structure of the mind map, and you need to be able to dynamically calculate the coordinate position information of the canvas where each node is located. Next, we will talk about how to calculate the coordinate information of the mind map and the definition of the data structure.

Data structure

Metadata definition

The basic data of each node includes metadata such as name, width, height, position coordinates.

If you consider doing style editing later, you can also store some style information, etc.

{<!-- -->
  id: '', // unique identifier of the node
  text: 'node name', // node name
  parentId: '', // parent node name
  width: 0, // node width
  height: 0, // node height
  x: 0,
  y: 0,
  marks: [], // List of marks on the node
  link: '', // hyperlink
  imageInfo: '', // Image information
  comment: '', // remarks
  // ... attrs more metadata
}
Data format definition

Because developing a mind map requires designing the add, delete, modify, and check of nodes, how to reasonably define the data format has a great relationship with our subsequent data processing.

The first type: tiled data

const list = [
  {<!-- --> id: '', text: '', width: '', height: '', // ···· },
  {<!-- --> id: '', text: '', width: '', height: '', // ···· },
  {<!-- --> id: '', text: '', width: '', height: '', // ···· },
  {<!-- --> id: '', text: '', width: '', height: '', // ···· },
  {<!-- --> id: '', text: '', width: '', height: '', // ···· }
]

The tiled data format will make it more convenient for us to operate on the original data, directly using some methods of the array (find, findIndex, splice ) to update the specified data. If you add a node, just push. However, each data needs to store a parentId to facilitate calculation of node coordinates. Perform tree formatting.

Data tile to tree format

/**
 * list transform tree
 * @param {array} data original data
 * @param {string} unique identifier of data id
 * @param {string} parent node associated field name parentId
 */
exports.transformTree = (data, key, pkey) => {<!-- -->
  if (!Array.isArray(data)) {<!-- -->
    throw new Error('data is must be array.')
  }
  const clonedata = this.deepClone(data)
  const map = clonedata.reduce((prev, cur) => {<!-- -->
    prev[cur[key]] = cur
    return prev
  }, {<!-- -->})
  const transformdata = []
  for (let i = 0; i < clonedata.length; i + + ) {<!-- -->
    const parent = map[clonedata[i][pkey]]
    if (!parent) {<!-- -->
      transformdata.push(clonedata[i])
    } else {<!-- -->
      parent.children = [...(parent.children || []), clonedata[i]]
    }
  }
  return transformdata
}

Advantages:

  • It’s easy to operate on raw data, no need to use recursion

Disadvantages

  • Every time you calculate node coordinates, you need to convert the tiled data structure into a tree structure.
  • The data structure relationship is not clear, and the parent-child correspondence is not obvious.

Second type: tree data

const root = {<!-- -->
  id: '',
  text: '',
  // ... attrs,
  children: [
    {<!-- -->
      id: '',
      text: '',
      // ... attrs
    }
  ]
}

Advantages:

  • The data structure relationship is clear, and the parent-child correspondence is obvious.

Disadvantages:

  • Operating original data requires recursion, which is more complicated.

Through the above comparison, the author finally used the tree structure data format to store the original data.

Data processing

To realize a mind map, it is not enough to only have node information. We also need node connection information. So how to generate corresponding connection data information based on the relationship between nodes? We can use d3.js to generate node connection information.

Node instantiation
import {<!-- --> hierarchy, tree } from 'd3-hierarchy'

/**
 * Tile all node data according to the root node root and assign x/y coordinates to the node
 * @param {*} root
 */
function dataTreeLayoutPackage (root) {<!-- -->
  const d3Tree = tree()
  const hierarchydata = d3Tree(hierarchy(root, d => d.children))
  nodes = hierarchydata.descendants()
  links = hierarchydata.links()
  return {<!-- -->
    nodes,
    links
  }
}

Through the above method, we can obtain an array of nodes and an array of links. nodes contains the data information of each node. links is the connection information between each two nodes.

Let’s first take a look at the format of each piece of data in nodes and links.

// node
{<!-- -->
  children: [Node],
  data: {<!-- --> text: '', id: '', // ... },
  depth: 1,
  height: 1,
  parent: Node,
  x: 0,
  y: 0
}

// link
{<!-- -->
  source: Node,
  target: Node
}

Data interpretation

  • parent: node instance information
  • children: children instance collection
  • depth: The level at which the node is located
  • data: original data information
  • source: parent node instance information
  • target: child node instance information

As you can see from the above data, the node instance data contains information such as height, x, y, etc., but these data cannot be used directly. Yes, we also need to obtain the width, height, x, y of the real node through our own secondary calculation. > Wait for data information.

Node data calculation

Get width and height

From the above, we can find that the text in each node can be different, so the width and height of the node are related to the content of the text. So how can we calculate the actual width and height of the node through the text data?

Idea: We can dynamically generate a tag through js, then put the text content into the tag, and then render the tag to html above, then the width and height of this label are the width and height of our node.

/**
 * Get the text width and height after wrapping long text
 * @param {*} options
 */
function getTextNodeRect (options) {<!-- -->
  const {<!-- -->
    text,
    fontSize,
    fontWeight = 'normal',
    fontFamily = "Microsoft YaHei, 'Microsoft YaHei'",
    fontStyle = 'normal'
  } = options
  const textSpan = document.createElement('p')
  const spanStyle = {<!-- -->
    maxWidth: '300px',
    fontSize: fontSize + 'px',
    fontWeight,
    fontFamily,
    fontStyle,
    whiteSpace: 'pre-wrap',
    display: 'inline-block',
    position: 'fixed',
    left: '-2000px',
    wordBreak: 'break-all',
    lineBreak: 'anywhere'
  }
  for (const key in spanStyle) {<!-- -->
    textSpan.style[key] = spanStyle[key]
  }
  textSpan.innerText = text
  document.body.append(textSpan)
  const {<!-- --> width, height } = textSpan.getBoundingClientRect()
  textSpan.remove()
  return {<!-- -->
    width: fontStyle === 'italic' ? width + 2 : width,
    height
  }
}

Now that the width and height of the node are known, we need to dynamically calculate the x and y coordinates of each node.

Positioning
Get the x coordinate of the node

First, we position the root node in the middle of the canvas, and then traverse the child nodes. Then the left of the child node is the left of the root node + the width of the root node + the marginX between them, as shown in the following figure:

image.png

Then traverse the child nodes of each child node (actually recursive traversal) to calculate left in the same way. In this way, after one traversal is completed, the left values of all nodes are calculated, and the x/y coordinates of the root node can be initialized.

function firstWalk (nodes) {<!-- -->
  nodes.forEach(node => {<!-- -->
    node.x = node.parent.x + node.parent.width + marginX
  })
}
Get the y coordinate of the node

Next is top. First of all, only the top of the root node is determined at the beginning. So how can the child nodes be positioned according to the top of the parent node? Woolen cloth? As mentioned above, each node is displayed centered relative to all its child nodes, so if we know the total height of all child nodes, then the top of the first child node is also determined:

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

as the picture shows:

image.png

The top of the first child node is determined, and other nodes only need to accumulate on the top of the previous node.

How to calculate the childrenAreaHeight of a node?

//First traversal
function firstWalk (nodes) {<!-- -->
  nodes.forEach(node => {<!-- -->
      node.childrenAreaHeight = (node.children || []).reduce((prev, cur) => {<!-- -->
        return prev + cur.height
      }, 0) + (len - 1) * 16
    }
  })
}

This step can be put together with the X coordinates of the calculated nodes above, and it only needs to be traversed once.

Next, start the second round of traversal. This round of traversal can calculate the top of all nodes.

//Second traversal
function secondWalk (nodes) {<!-- -->
  nodes.forEach(node => {<!-- -->
    if (hasChild(node)) {<!-- -->
      const y = node.y + node.height / 2 - node.childrenAreaHeight / 2
      let startY = y
      node.children.forEach(n => {<!-- -->
        n.y = startY
        startY + = n.height + marginY
      })
    }
  })
}

Things don’t end here, please see the picture below:

image.png

It can be seen that for each node, the position is correct, but overall it is not right because there is overlap. The reason is very simple, because [secondary node 1] has too many child nodes. The total height occupied has exceeded the height of the node itself, because the positioning of the [secondary node] is calculated based on the total height of the [secondary node], and its child nodes are not taken into account. The solution is also very simple. Next In one round of traversal, when it is found that the total height of the child nodes of a node is greater than its own height, the nodes before and after the node are moved outward. For example, in the figure above, assume that the height of the child nodes is greater than the height of the node itself. The height is 100px more, then we will move [Secondary Node 2] down 50px. If there are nodes above it, also move it up 50px. It should be noted that this adjustment process needs to go all the way to the parent node. Bubbling on top.

The total height of the child elements of [child node 1-2] is obviously greater than itself, so [child node 1-1] needs to move upward. This is obviously not enough. Assume that there are child nodes of [secondary node 0] above, then They may also overlap, and the [child node 2-1-1] and [child node 1-2-3] below are obviously too close, so [child node 1-1]’s own sibling nodes are adjusted After that, the sibling nodes of the parent node [Secondary Node 1] also need to be adjusted in the same way. The upper ones move up and the lower ones move down, until they reach the root node:

//The third traversal
function thirdWalk (nodes) {<!-- -->
  nodes.forEach(node => {<!-- -->
    const difference = node.childrenAreaHeight - node.height
    if (difference > 0) {<!-- -->
      updateBrothers(node, difference / 2)
    }
  })
}

updateBrothers is used to move sibling nodes upward recursively:

function updateBrothers (node, addHeight) {<!-- -->
  if (node.parent) {<!-- -->
    const childrenList = node.parent.children
     //Find which node you are at
    const index = childrenList.findIndex(item => item === node)
    childrenList.forEach((item, _index) => {<!-- -->
      if (item === node) return
      let _offset = 0
      if (_index < index) {<!-- -->
      //Move the upper node up
        _offset = -addHeight
      } else if (_index > index) {<!-- -->
         //Move the following nodes down
        _offset = addHeight
      }
      //Move node
      item.y + = _offset
      // The node itself has moved, and all its subordinate nodes need to be moved simultaneously.
      if (hasChild(item)) {<!-- -->
        updateChildren(item.children, 'y', _offset)
      }
    })
    updateBrothers(node.parent, addHeight)
  }
}

Update the coordinates of all child nodes:

//Update the positions of all child nodes of the node
function updateChildren (children, prop, offset) {<!-- -->
  children.forEach((item) => {<!-- -->
    item[prop] + = offset
    if (hasChild(item)) {<!-- -->
      updateChildren(item.children, prop, offset)
    }
  })
}

At this point, the entire layout calculation of the [Logical Structure Diagram] is completed. Of course, there is a small problem:

That is, strictly speaking, a node may no longer be centered relative to all its child nodes, but centered relative to all descendant nodes.

Node connection

After the node is positioned, the next step is to connect the node with all its sub-nodes. There are many connection styles. You can use straight lines or curves. Straight lines are very simple because the left and top of all nodes , width, and height are already known, so the turning point coordinates of the connecting line can be easily calculated:

image.png

It can also be connected by curves (quadratic Bezier curve, cubic Bezier curve)

image.png

This simple curve can use a quadratic Bezier curve, with the starting point coordinate being the middle point of the root node:

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

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

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

Then you only need to determine a control point. You can adjust this specific point yourself and find a position that is pleasing to the eye. Finally, choose the midpoint of the two points as the control point.

let cx = x1 + (x2 - x1) * 0.5
let cy = y1 + (y2 - y1) * 0.5

Next, just add a rendering connection method:

export function renderNewEdges (links) {<!-- -->
  const enter = edgeContainer
    .selectAll('g')
    .data(links)
    .enter()
    .append('g')
  enter
    .append('path')
    .attr('d', d => {<!-- -->
      const sx = d.source.width + d.source.x
      const sy = d.source.y + d.source.height
      const tx = d.target.x
      const ty = d.target.y + d.target.height / 2
      const cx = sx + (tx - sx) * 0.5
      const cy = sy + (ty - sy) * 0.5
      return `M${<!-- -->sx} ${<!-- -->sy} Q${<!-- -->cx} ${<!-- -->cy} ${ <!-- -->tx} ${<!-- -->ty}`
    })
    .attr('stroke-linecap', 'round')
    .attr('stroke', d => d.source.style.lineStyle.fill)
    .attr('stroke-width', 2)
    .attr('fill', 'none')
}

Basic functions

  • Supports the insertion of icons, labels, illustrations, pictures, hyperlinks, notes and other data
  • Supports single node summary information insertion
  • Supports the insertion of relationship connections between two nodes
  • Canvas export (supports .png, .svg, .json formats)
  • .json file import
  • Supports saving canvas history in the previous step and next step
  • Implementation of shortcut keys for some operations
  • canvas thumbnail
  • Single node style modification (font, background color, connection style, icon size, node inner and outer margins, etc.)
  • Theme modification
  • Brain map structure switching (currently only supports logical structure diagrams, mind maps, bracket diagrams and organizational charts)

Demo address

web mind map

Git warehouse address

x-mind-map