Mindmap Builder
This application is a simple mindmap builder. A single main topic sits in the center of the layout, and subtopics can be built up in trees to the left and to the right. Each topic can have notes attached.
This application provides examples of a few of the more advanced ways of using the Toolkit: it has a custom layout, dataset parser and dataset exporter.

On this page we discuss the "vanilla" implementation. There are implementations available for each of our library integrations:
The source code for this application is available on Github at https://github.com/jsplumb/jsplumbtoolkit-applications.
Overview
The main features of this application are:
A drawing canvas which features a main topic in the center of the layout
A tree of subtopics to the left and to the right of the main topic
Each topic can have notes attached
A miniview
A set of controls for managing zoom, undo, redo, etc.
Note that the code snippets on this page refer to constants in many places, which, for the sake of brevity, we have not included. You can find these in the source code on Github.
Imports
"dependencies":{
"@jsplumbtoolkit/browser-ui":"^6.5.0"
}
You need at least version 6.5.0 of the Toolkit to run this application.
Setup
At the heart of any application using the Toolkit is some instance of the Toolkit edition. In this application we create a basic Toolkit instance:
const toolkit = newInstance();
Rendering the canvas
This is the call to render the Toolkit instance. There are a number of things going on in here; we've documented this inline but also in subsequent sections we'll expand on various bits and pieces from here.
const container = document.querySelector(".jtk-demo-canvas")
const surface = toolkit.render(container, {
// in this app, elements are not draggable; they are fixed by the layout.
elementsDraggable:false,
// after load, zoom the display so all nodes are visible.
zoomToFit:true,
// show connections to ports as being attached to their parent nodes. We use this for the main node: its edges
// are connected to either a `right` or `left` port on the main node, but these ports are logical ports only - they
// do not have their own DOM element assigned.
logicalPorts:true,
// Run a relayout whenever a new edge is established, which happens programmatically when the user adds a new subtopic.
refreshLayoutOnEdgeConnect:true,
// Use our custom mindmap layout.
layout:{
type:MindmapLayout.type
},
view:{
nodes:{
main:{
template:`<div class="jtk-mindmap-main jtk-mindmap-vertex">
{{label}}
<div class="${CLASS_MINDMAP_INFO}"></div>
<div class="${CLASS_ADD_CHILD}" data-direction="${LEFT}"></div>
<div class="${CLASS_ADD_CHILD}" data-direction="${RIGHT}"></div>
</div>`
},
subtopic:{
template:`<div class="jtk-mindmap-subtopic jtk-mindmap-vertex">
{{label}}
<div class="${CLASS_MINDMAP_INFO}"></div>
<div class="${CLASS_ADD_CHILD}" data-direction="{{direction}}"></div>
<div class="${CLASS_MINDMAP_DELETE}"></div>
</div>`
}
}
},
modelEvents:[
{
event:EVENT_TAP,
selector:`.${CLASS_ADD_CHILD}`,
callback:(event, eventTarget, modelObject) => {
// read out the direction for this edge.
const direction = eventTarget.getAttribute("data-direction")
// for edges from the main node, we attach them to a port on the node, because the main node can
// have `left` and `right` edges. For subtopic nodes we attach directly to the node. So this code tests
// for a matching port and uses it as the source if found, otherwise it uses the source node.
const source = modelObject.obj.getPort(direction) || modelObject.obj
const payload = {
id:uuid(),
parentId:modelObject.obj.id,
label:"New subtopic",
children:[],
type:SUBTOPIC,
direction
}
toolkit.transaction(() => {
const node = toolkit.addNode(payload)
toolkit.addEdge({source, target:node})
})
}
},
{
event:EVENT_TAP,
selector:`.${CLASS_MINDMAP_DELETE}`,
callback:(event, eventTarget, modelObject) => {
// select the node that was clicked and all of its descendants (we get a Selection object back)
const nodeAndDescendants = toolkit.selectDescendants(modelObject.obj, true)
// inside a transaction, remove everything in that selection from the Toolkit (including edges to each of the nodes).
// we do this inside a transaction so we can undo the whole operation as one unit.
toolkit.transaction(() => {
toolkit.remove(nodeAndDescendants)
})
}
},
{
event:EVENT_TAP,
selector:`.${CLASS_MINDMAP_INFO}`,
callback:(event, eventTarget, modelObject) => {
toolkit.setSelection(modelObject.obj)
}
}
],
defaults:{
connector:{
type:SegmentedConnector.type,
options:{
stub:20
}
},
anchor:[
AnchorLocations.Left, AnchorLocations.Right
],
endpoint:BlankEndpoint.type
},
plugins:[
{
type:MiniviewPlugin.type,
options:{
container:document.querySelector(".miniview")
}
}
]
})
Nodes
Rendering nodes
There are two node types in this application - main
and subtopic
. There's a single main
node in the center of the layout, and all other nodes are of type subtopic
.
nodes:{
main:{
template:`<div class="jtk-mindmap-main jtk-mindmap-vertex">
{{label}}
<div class="${CLASS_MINDMAP_INFO}"></div>
<div class="${CLASS_ADD_CHILD}" data-direction="${LEFT}"></div>
<div class="${CLASS_ADD_CHILD}" data-direction="${RIGHT}"></div>
</div>`
},
subtopic:{
template:`<div class="jtk-mindmap-subtopic jtk-mindmap-vertex">
{{label}}
<div class="${CLASS_MINDMAP_INFO}"></div>
<div class="${CLASS_ADD_CHILD}" data-direction="{{direction}}"></div>
<div class="${CLASS_MINDMAP_DELETE}"></div>
</div>`
}
}
We write out buttons to add children to the left and the right of the main node, and on subtopics we write out a single add child button, using the data-direction
attribute and some css to position the button on the left or right of the element. Additionally, subtopic nodes have a delete button, but main nodes do not.
Adding subtopics
In the modelEvents
section of the render parameters, we map this:
{
event:EVENT_TAP,
selector:`.${CLASS_ADD_CHILD}`,
callback:(event, eventTarget, modelObject) => {
// read out the direction for this edge.
const direction = eventTarget.getAttribute("data-direction")
// for edges from the main node, we attach them to a port on the node, because the main node can
// have `left` and `right` edges. For subtopic nodes we attach directly to the node. So this code tests
// for a matching port and uses it as the source if found, otherwise it uses the source node.
const source = modelObject.obj.getPort(direction) || modelObject.obj
const payload = {
id:uuid(),
parentId:modelObject.obj.id,
label:"New subtopic",
children:[],
type:SUBTOPIC,
direction
}
toolkit.transaction(() => {
const node = toolkit.addNode(payload)
toolkit.addEdge({source, target:node})
})
}
}
First, the handler determines which direction (left
or right
) the button is targeting. Then we find the appropriate source for the new edge: if the source node is the main node, we find the port on the main node that maps the direction, otherwise with subtopic nodes we use the node itself.
We then create a suitable payload for a new subtopic, and, in a transaction, add this new subtopic node and connect it to the source. We use a transaction for this because we can then undo/redo the operation as one atomic unit.
Editing nodes
Nodes are edited via an inspector, declared in mindmap-inspector.js
. This is a class that extends VanillaInspector:
export class MindmapBuilderInspector extends VanillaInspector {
constructor(options) {
super(Object.assign(options, {
templateResolver:(obj) => {
return `
<div class="jtk-inspector jtk-node-inspector">
<div class="jtk-inspector-section">
<div>Label</div>
<input type="text" jtk-att="${PROPERTY_LABEL}" jtk-focus/>
</div>
<div class="jtk-inspector-section">
<div>Notes</div>
<textarea rows="10" jtk-att="${PROPERTY_NOTES}"/>
</div>
</div>`
}
}))
}
}
The key piece here is the templateResolver
: VanillaInspector
will call this method when it needs to render an inspector for some object (or set of objects). In this application we only support inspecting nodes, but if you wanted to extend this app to add edge labels, you could handle that here.
Edges
Rendering edges
There is no specific mapping for edges in this application, but the appearance of edges is controlled in the defaults
section of the render parameters:
defaults:{
connector:{
type:SegmentedConnector.type,
options:{
stub:20
}
},
anchor:[
AnchorLocations.Left, AnchorLocations.Right
],
endpoint:BlankEndpoint.type
}
This application uses the Segmented connector, which is new in 6.5.0.
Miniview
We use a miniview in this starter app. It is declared as one of the plugins in the render
call:
plugins:[
{
type:MiniviewPlugin.type,
options:{
container:document.querySelector(".miniview")
}
}
]
Controls
Here we use another component that was created for our demonstrations but which now ships with the Toolkit, as we thought it might be useful for others:
import { ControlsComponent } from "@jsplumbtoolkit/browser-ui"
// handler for clear dataset, zoom to fit etc.
new ControlsComponent(document.querySelector(".jtk-controls-container"), surface)
Note that in this application we have not attached a Lasso
plugin to the surface, and the controls component will detect this fact, and omit the mode change buttons.
Data Model
In the discussion of node rendering above we mentioned that we use on the main node. For a discussion of ports, see this page. Ports are basically connection points on some vertex, and a vertex may have zero or an arbitrary number of them. In this application we have a left
and a right
port on the main node.
Ports may either be represented in the UI by a specific DOM element, which we refer to as "physical" ports, or they may not be represented by a specific DOM element, in which case we use the DOM element for their parent vertex, and we nominate these "logical" ports. To switch on support for logical ports it is necessary to add a flag to the render parameters:
{
...
logicalPorts:true
}
Using ports on the main node make it simple for us to keep the left and right subtrees separate in the data model. The subtopic nodes do not use ports - edges to and from these nodes connect directly to the vertices themselves.
Main node
In this application we always want to have a main node present, so we listen for the EVENT_GRAPH_CLEARED
event, and in response we add a new main node:
// bind to graph cleared event and add a new main node, then center it.
toolkit.bind(EVENT_GRAPH_CLEARED, () => {
toolkit.addNode({
id:uuid(),
type:MAIN,
left:[],
right:[],
label:"Main"
})
surface.zoomToFit()
})
Layout
This applications uses a custom layout, found in mindmap-layout.js
. A custom layout must extend AbstractLayout
and provide implementations for several key methods - a full discussion of custom layout can be found here.
Custom layouts may or may not wish to take action in response to every lifecycle event. Our mindmap layout respond to begin
and step
:
begin(toolkit, parameters) {
const focusCandidates = toolkit.filter(o => isNode(o) && o.data.type === MAIN)
if (focusCandidates.getNodeCount() > 0) {
this.focusVertex = focusCandidates.getNodeAt(0)
} else {
this.focusVertex = null
}
}
In our begin
method we try to find the main
node in the dataset.
The step method is where the bulk of the work occurs in a layout. It is called repeatedly until it sets done
on the layout. Many layouts, such as this one, can be computed in one pass, but others, such as the Force directed layout, are iterative.
step(toolkit, parameters) {
if (this.focusVertex != null) {
//
// We use a helper class here to draw out the left/right trees - ParentRelativePlacementStrategy.
//
const _preparePlacementStrategy = (dir) => {
return new ParentRelativePlacementStrategy(toolkit, {
rootNode:this.focusVertex,
idFunction:(d) => d.id,
sizeFunction:(id) => {
return this._getSize(id)
},
childVerticesFunction:(d) => {
if (d.data.type === MAIN) {
return d.getAllEdges().filter(e => e.target.data.direction === dir).map(e => e.target)
} else {
return d.getAllEdges().map(e => e.target)
}
},
padding:{x:250, y:100},
absolutePositionFunction:(v) => null,
axisIndex:1
})
}
const rightPositions = _preparePlacementStrategy(RIGHT).execute()
rightPositions.forEach((info, id) => {
this.setPosition(id, info.position.x, info.position.y)
})
const leftPositions = _preparePlacementStrategy(LEFT).execute()
leftPositions.forEach((info, id) => {
this.setPosition(id, info.position.x * -1, info.position.y)
})
}
this.done = true
}
The bulk of the work in this method is handed off to the ParentRelativePlacementStrategy
helper class, which started out as the core of the Hierarchical
layout but which we extracted a while ago to share with the new Hierarchy
layout. I have to admit I was quite delighted to find this could be easily reused for the mindmap layout.
Parser
We use a custom parser in this application. Our JSON looks like this:
{
"name": "test dataset",
"data":{
"id": "1",
"label": "Main Topic Main Topic",
"notes": "this is the main topic",
"right": [
{
"id": "2",
"label": "Topic 2 Topic 2",
"notes": "",
"children": [
{
"id": "9",
"label": "Topic 9 RIGHT",
"notes": ""
}
]
},
{
"id": "3",
"label": "Topic 3 Topic 3",
"notes": ""
}
],
"left": [
{
"id": "4",
"label": "Topic 4 Topic 4 (LEFT)",
"notes": ""
},
{
"id": "5",
"label": "Topic 5 Topic 5 (LEFT)",
"notes": "",
"children": [
{
"id": "6",
"label": "Topic 6 Topic 6 (LEFT)",
"notes": ""
}
]
}
]
}
}
... there is a single implicit main node in the root of the data
entry, which has a right
and left
child, each of which is an array of subtopics. Each subtopic may then declare a list of children
.
The parser code looks like this:
const mindmapJsonParser = (jd, toolkit, parameters) => {
const json = typeof jd === "string" ? JSON.parse(jd) : jd
const data = json.data
data.type = MAIN
let mainTopic = toolkit.addNode(data)
// add logical ports for connections to each side of the
// main node
mainTopic.addPort({id:LEFT})
mainTopic.addPort({id:RIGHT})
const _processChildren = (focus, direction) => {
const c = focus.data.children || []
c.forEach((_c) => {
_c.direction = direction
_c.type = SUBTOPIC
_c.children = _c.children || []
const __c = toolkit.addNode(_c)
toolkit.addEdge({source:focus, target:__c})
_processChildren(__c, direction)
})
}
const _processRootChildren = (direction) => {
const n = data[direction]
const source = mainTopic.getPort(direction)
n.forEach(l => {
l.type = SUBTOPIC
l.direction = direction
l.children = l.children || []
const ln = toolkit.addNode(l)
toolkit.addEdge({source, target:ln, data:{direction:direction}})
_processChildren(ln, direction)
})
}
_processRootChildren(LEFT)
_processRootChildren(RIGHT)
}
registerParser(MINDMAP_JSON, mindmapJsonParser)
Notice how the first line checks the type of the incoming data, and parses it to JSON if necessary. You should do this if you write your own parser.
The parser code first adds the data
section as a node (after setting type
to MAIN), and it then adds the left
and right
ports to the main node as discussed above. _processRootChildren
is then invoked for each of LEFT and RIGHT. It finds the appropriate port on the main node to use a source, then adds subtopic nodes for each of the subtopics that are children of the main node in the given direction. _processChildren
then recurses on each subtopic's children
array to build out the tree.
The key piece you should not forget when writing your own parser is to register it:
import { registerParser } from "@jsplumbtoolkit/browser-ui"
registerParser(MINDMAP_JSON, mindmapJsonParser)
To invoke this parser you need to tell the Toolkit you want to use it:
toolkit.load({
data:someDataset,
type:MINDMAP_JSON
})
Exporter
You'll typically want to write your own exporter if you have a custom parser. Our exporter (also in parser.js
) looks like this:
const mindmapJsonExporter = (toolkit, parameters) => {
const mainTopic = toolkit.filter(o => isNode(o) && o.data.type === MAIN).getNodeAt(0)
const _one = (v, direction) => {
const edges = v.getAllSourceEdges()
const d = {
id:v.data.id,
type:SUBTOPIC,
direction,
label:v.data.label,
notes:v.data.notes || "",
children:edges.map(e => _one(e.target, direction))
}
return d
}
const mainLeft = mainTopic.getAllEdges().filter(e => e.target.data.direction === LEFT)
const mainRight = mainTopic.getAllEdges().filter(e => e.target.data.direction === RIGHT)
const left = mainLeft.map(ml => _one(ml.target, LEFT))
const right = mainRight.map(ml => _one(ml.target, RIGHT))
return {
name:"mindmap",
data:{
id:mainTopic.data.id,
label:mainTopic.data.label,
notes:mainTopic.data.notes || "",
left,
right
}
}
}
registerExporter(MINDMAP_JSON, mindmapJsonExporter)
- We first find the main node by filtering the Toolkit.
- Next, we call
getAllEdges()
on the main node, and filter thedirection
value in the target for each edge, splitting them into a list ofleft
andright
edges. - We then call the recursive
_one
method with each edge in the left and right lists. - Lastly, we build the final dataset.
To export the Toolkit's dataset using this exporter you need to tell the Toolkit you want to use it:
toolkit.exportData({
type:MINDMAP_JSON
})
Export parameters
Note the hardcoded "mindmap"
name in the exporter code above - we could in fact have passed that in via the parameters we passed to exportData
:
toolkit.exportData({
type:MINDMAP_JSON,
name:"My MindMap"
})
and then the exporter code could be updated as such:
return {
name:parameters.name || "mindmap",
data:{
id:mainTopic.data.id,
label:mainTopic.data.label,
notes:mainTopic.data.notes || "",
left,
right
}
}
CSS
Styles for the app itself are in app.css
.
This app also imports a few of the CSS files that ship with the Toolkit:
<link rel="stylesheet" href="node_modules/@jsplumbtoolkit/browser-ui/css/jsplumbtoolkit.css">
<link rel="stylesheet" href="node_modules/@jsplumbtoolkit/browser-ui/css/jsplumbtoolkit-controls.css">
jsplumbtoolkit.css
provides basic 'structural' CSS, and it is recommended that you include this stylesheet in your cascade, at least when you first start developing with the Toolkit.
jsplumbtoolkit-controls.css
contains styles for the controls component, which was created for our demonstrations but since 6.2.0 has been shipped with the Toolkit as it may be useful for others.