Data Model
We recommend reading through this page in its entirety, to get a solid understanding of the data model that powers JsPlumb. Everything else in the documentation will make more sense if you're across the concepts in this page.
The core model abstraction in JsPlumb is that of a graph, or more properly, a directed graph, as discussed here on Wikipedia.
A graph is a collection of nodes, groups, edges and ports. In the above screenshot - from our groups feature demonstration - we see groups, nodes and edges identified.
- Nodes map to entities in your data model.
- Groups are collections of nodes/groups.They map to entities in your data model.
- Ports are points on your nodes/groups that are the endpoint of some relationship with another node/group, or with a port on another node/group.
- Edges are relationships between nodes, groups or ports.
In the JsPlumb documentation, and in the apidocs, you'll see references to Vertex
and vertices
- this is a node, port or group. We often use "vertex" to refer to the source and target terminus for an edge throughout the documentation as it's easier than typing "Node/Group/Port"...and also, conceptually, its correct!
Example - Schema Builder
As an example of how these data types map to an application - in the Schema Builder starter application - nodes, edges and ports are mapped as follows:
- Nodes are tables (or views) in a database schema
- Ports are columns on a table node
- Edges are relationships between columns on two table nodes
Here we see the Book
table, which is modelled as a node, with three columns id
, isbn
and title
, which are modelled as ports on the Book node:
Example - Flowchart Builder
In the Flowchart Builder application,nodes, edges and ports are mapped as follows:
- Nodes are objects in the flowchart such as questions or actions.
- Ports are used to model the yes/no connection points from a Question. Action nodes do not have ports as they have only one output and this is assigned to the node itself.
- Edges are the control flow of the chart.
Every application using JsPlumb will have its own mapping of concepts to nodes, groups, edges and ports. Some applications may not even use ports at all, since every node can be an edge target and/or source. ports just give you the ability to model more complex data structures.
Paths
In addition to nodes, groups, ports and edges, JsPlumb has the concept of a Path: an in-order list of edges and vertices that represent the path from one vertex to some other vertex. These are a very useful way of querying, and operating on, your model. Paths are discussed in a separate page.
Data Format
The various parts of your data model can be represented as any valid Javascript type. As an example, here is the backing data for the Book
table in the Schema Builder starter app:
{
"id":"book",
"name":"Book",
"type":"table",
"columns":[
{ "id":"123", "name":"id", "datatype":"integer", "primaryKey":true },
{ "id":"456", "name":"isbn", "datatype":"varchar" },
{ "id":"789", "name":"title", "datatype":"varchar" }
]
}
JsPlumb has its own internal representation of its data members, but your original data is always stored under the data
member - this is the Node
from above:
{
"id":"456",
"data":{
"id":"book",
"name":"Book",
"type":"table",
"columns":[
{ "id":"123", "name":"id", "datatype":"integer", "primaryKey":true },
{ "id":"456", "name":"isbn", "datatype":"varchar" },
{ "id":"789", "name":"title", "datatype":"varchar" }
]
}
}
Port Data
In the node data given above there is a columns
array - each entry in this array maps to a port on the node representing that table. The IDs of the ports here are the id
members from each column in the array.
Object IDs
Every vertex is required to have a unique ID. JsPlumb attempts to derive this automatically from your data, by looking for an id
member - which should be a string.
Should you wish to implement a different strategy, though, you can just supply your own idFunction
to the method you use to get an instance of JsPlumb:
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const jsplumb = newInstance({
idFunction:(data:ObjectData):string {
return SomeCustomComputing(data);
}
})
Again note this is optional - you do not need to supply this function, but if you do not then JsPlumb will expect an id
member in your data. Remember to pass back the ID as a string. This method will be used to attempt to derive an ID for any model object.
Group IDs
IDs for groups are derived using whatever method JsPlumb is using to derive IDs for nodes - either by looking for an id
value in your data, or by using a supplied idFunction
. group IDs must be unique across all groups and nodes in the dataset: you cannot have a group that has the same ID as some node.
Edge IDs
You are not required to supply an ID for every edge, but if you do not, JsPlumb will assign one automatically.
Port and edge ID functions
You can supply separate functions to use for extracting the ID for a port or edge from some backing data:
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
idFunction:(data:ObjectData) => {
return SomeCustomComputing(data)
},
portIdFunction:(data:ObjectData) => {
return portComputations(data)
},
edgeIdFunction:(data:ObjectData) => {
return edgeComputations(data)
}
})
If you omit either of these, JsPlumb uses whatever it is using to derive node IDs (either the idFunction
, if provided, or the default mechanism of looking for an id
member).
Port ID Uniqueness
Port IDs are required to be unique on the node on which the port exists, but may be the same as the ID of a port on some other node (and in fact this is quite common).
Referencing ports by ID
When adding an edge to a JsPlumb instance, you can reference a port on some node using, by default, dotted notation. In the data given above there were three ports. We could connect one of them to a column on another table like this:
toolkit.connect({
source:"book.id",
target:"book_author.book_id"
})
book_author
is another table in the schema from the Schema Builder starter app.
Custom port ID Separator
If you find that using a period as the separator in a port ID does not work for your data model, you can override what JsPlumb will use by doing this:
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const tk = newInstance({
portSeparator:"#"
})
Extracting and synchronizing port data
When you load data into an instance of JsPlumb and you have nodes/groups that themselves have ports (consider the Schema Builder starter app - a table is a node, and the columns on that table are ports), the data describing your ports will typically be held within the node data. For instance, in the Schema Builder, a table node looks like this:
{
"id":"book",
"name":"Book",
"type":"table",
"columns":[
{ "id":"123", "name":"name", "type":"varchar" },
{ "id":"456", "name":"id", "type":"integer" }
]
}
The entries in the columns
array are the ports for this node. In order to get JsPlumb to recognise these ports when using the load
function, you should define a portExtractor
when you create an instance of the Toolkit:
import { newInstance,
ObjectData,
Node,
Group } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"portExtractor": (data:ObjectData, parent:Node|Group):Array<ObjectData> => {
return data.columns || [];
}
})
const surface = toolkit.render(someElement, {});
Synchronizing node data with port data
A further consideration with the setup discussed above is how to go about keeping the original data, now stored in the node/group, in sync with the reality of the current ports and their backing data. To do this, you can specify a portUpdater
function in the arguments to the getInstance
call:
import { newInstance,
ObjectData,
Node,
Group,
Port } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"portUpdater": (data:ObjectData, parent:Node|Group, ports:Array<Port>) => {
return Object.assign(data, {
columns:ports.map((p) => p.data)
})
}
})
const surface = toolkit.render(someElement, {});
data
is the node's current data. node
is the node itself, and ports
is an array of port objects. This example is from the original version of the Database Visualizer demo, which now uses the portDataProperty
described below as it is a simple use case). In practise we find that people using a library integration need only define a portDataProperty
, since their library manages keeping the data up to date.
Simple synchronisation
In many cases the portExtractor
and the portUpdater
end up being a matched pair: the extractor returns some array property, and the updater simply extracts the data from each port and returns it in an object whose key is that same property name and whose value is an array:
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
portExtractor:function(data, node) { return data.columns || []; },
portUpdater:function(data, node, ports) {
return jsPlumb.extend(data, {
columns:jsPlumbToolkitUtil.map(ports, function(p) { return p.data; })
});
}
})
import { newInstance,
ObjectData,
Node,
Group,
Port } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"portExtractor": (data, node) => { return data.columns || []; },
"portUpdater": (data, node, ports) => {
return Object.assign(data, {
columns:ports.map(p => p.data)
});
}
})
const surface = toolkit.render(someElement, {});
In these situations you can replace both functions by declaring a portDataProperty
:
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"portDataProperty": "columns"
})
const surface = toolkit.render(someElement, {});
This will cause JsPlumb to create the portExtractor
and portUpdater
functions shown above. Note that this mechanism assumes all nodes in your dataset have port data keyed by this property. For nodes that don't have ports this is not a problem: an empty array will be returned and no ports will be registered.
Port Ordering
In some situations you may wish to specify the order of ports on a node (typically because it will help you render the UI the way you want). If you're using the portDataProperty
to tell JsPlumb which property holds port data, you can also specify a portOrderProperty
, which provides the ordering of the ports on a node. To take the previous example and add ordering:
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"portDataProperty": "columns",
"portOrderProperty": "order"
})
const surface = toolkit.render(someElement, {});
Given a node whose backing data looks like this:
{
"name":"A Table",
"id":"123456",
"columns":[
{ "name":"Type", "order":2, "id":"5234532" },
{ "name":"Nullable", "order":3, "id":"78947021" },
{ "name":"ID", "order":1, "id":"76486213" }
]
}
Then with the JsPlumb instance created above, the ports would be reordered by JsPlumb into the order ID
, Type
, Nullable
.
Object type
Every object has an associated type
. This is an important concept in JsPlumb, as it is the basic means by which the data model is bound to any renderers, via Views.
As with IDs, the type of a model object can be derived using a default function - which looks for a type
member in the backing data, or you can supply a typeFunction
to the newInstance
method:
import { newInstance,
ObjectData } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"idFunction": (data:ObjectData) => {
return SomeCustomComputing(data);
},
"typeFunction": (data:ObjectData) => {
return SomeOtherComputing(data);
},
"edgeTypeFunction": (data:ObjectData) => {
return edgeTypeComputing(data);
},
"portTypeFunction": (data:ObjectData) => {
return portTypeComputing(data);
}
})
const surface = toolkit.render(someElement, {});
As with ID, omitting either the portTypeFunction
or edgeTypeFunction
will cause JsPlumb to use whatever function it has determined it should use for deriving node types. Also, as with ID, JsPlumb will derive group types via the same means it is using to derive node types.
The type
of some model object is used by the view in a surface, to determine the appearance and behaviour of the object. For a discussion of views and rendering to a surface, see the surface and views documentation.
To change the type of some object after it has been initially created, use the setType
method on a Toolkit instance. For a discussion, see this page
Data Factories
Node factory
When you need to add a new node programmatically, you do so on the JsPlumb instance using the addNode
function:
addNode : function(data) {
...
}
It is expected that the current idFunction
and typeFunction
will be able to determine appropriate values from the data you provide. An example might be something like the following:
toolkit.addNode({
id:"place",
name:"Place",
columns:[
{ id:"id", name:"Id", primaryKey:true, datatype:"integer" },
{ id:"name", name:"Name", datatype:"varchar" },
{ id:"lat", name:"Latitude", datatype:"float" },
{ id:"lng", name:"Longitude", datatype:"float" }
]
})
If, however, you are using a drag and drop mechanism to add new nodes to the UI (for instance via a SurfaceDropManager), JsPlumb needs some way of getting the data for a new node. By default, JsPlumb will create a JS object with an id
member set to a new UUID, but you can provide an ObjectFactory
in order to gain control over the creation of the data.
An object factory is a function with this signature:
export declare type ObjectFactory = (type: any, data: any, continueCallback: Function, abortCallback: Function, params?: any) => boolean;
type
is the type of object to create.data
is the data appropriate to the given object type; in the case of drag and drop it has been generated elsewhere (for instance, via a SurfaceDropManager).callback
is a function that your code must call once it has the data for the new object prepared - having a callback rather than returning a value enables you to optionally prepare the data asynchronously, perhaps via a round trip to the server. If you do not call this function, addition of the new object is aborted (which you might want to do, sometimes, of course).originalEvent
This is the browser event related to the drop. IfisNative
is set, you might wish to access thedataTransfer
member of this event when deciding how to populate the data object.isNative
Set to true if the drop event was "native", for example a file dragged into the browser from the user's desktop or file system.
Direct callback
Here's an example of a direct callback:
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"nodeFactory": (type, data, callback, evt, native) => {
callback({
someKey:\aValue\",
someArray:[ 1, 2, 3 ]
});
}"
})
const surface = toolkit.render(someElement, {});
Ajax callback
You might do this instead if you want to get your new node from the server:
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"nodeFactory": (type, data, callback, evt, native) => {
makeAnAjaxCall({
url:\/get/new/\" + type,
success:callback
});
}"
})
const surface = toolkit.render(someElement, {});
Native drop
You might do this if it's a native drop:
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"nodeFactory": (type, data, callback, evt, native) => {
if (isNative) {
data.name = evt.dataTransfer.files[0].name;
data.size = evt.dataTransfer.files[0].size;
data.type = evt.dataTransfer.files[0].type;
}
callback(data);
}
})
const surface = toolkit.render(someElement, {});
Calling the node factory directly
You can call the node factory directly using the addFactoryNode
method:
toolkit.addFactoryNode("someType");
This will result in a call to the node factory with the type someType
.
It is possible to also provide some seed data for the new node:
toolkit.addFactoryNode("someType", { foo:"bar" });
And also you can provide a callback which will be run after the node factory has finished adding the new node:
toolkit.addFactoryNode("someType", { foo:"bar" }, function(node) {
// node is your new node.
});
Accessing the new object
The callback function provided to node/group factories returns the newly created object. Taking the example from above, for instance, we can assign the return value of callback:
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"nodeFactory": (type, data, callback, evt, native) => {
const newNode = callback({
someKey:\aValue\",
someArray:[ 1, 2, 3 ]
});
}"
})
const surface = toolkit.render(someElement, {});
This can be useful in certain situations. We were building an application for a client who wanted to edit newly dropped nodes as soon as they were dropped, so we initially set it up like this:
nodeFactory: function (type, data, callback) {
_editNode(data, true, (atts, cancelled) => {
if (!cancelled) {
callback(atts);
}
});
}
Here, _editNode
opens up a node inspector in a side panel, and after editing hits a callback with the data and a flag indicating whether the user cancelled the dialog.
The drawback with this approach was that the new node did not appear on the canvas while the user was editing the details. So we rewrote our first pass to be this:
nodeFactory: (type, data, callback) => {
var newNode;
setTimeout(() => {
_editNode(data, true, (atts, cancelled) => {
if (cancelled && newNode != null) {
toolkit.removeNode(newNode);
} else {
toolkit.updateNode(newNode, atts);
}
});
}, 0);
newNode = callback(data);
}
We assign the return value of the callback to newNode
, and set a timeout to call the node editor. The user sees the new node on the canvas and in the editor, and then the edit handler makes the decision about whether to remove the new node, or to update it with its newly entered information.
Group factory
A group factory works in exactly the same way as a node factory. Here's the setup of JsPlumb in the available on Github, the source code for which is available on Github.
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"groupFactory": (type, data, callback) => {
data.title = \Group \" + (toolkit.getGroupCount() + 1);
callback(data);
}",
"nodeFactory": (type, data, callback) => {
data.name = (toolkit.getNodeCount() + 1);
callback(data);
}
})
const surface = toolkit.render(someElement, {});
In this app we simply assign a label that has the group/node index in it.
Note that the group factory's callback
method will return the newly created group.
Port factory
If your data model is sufficiently complex, you will be making use of ports. In the same way that JsPlumb needs to be able to assemble a JS object for each new node created through user interaction, it needs a way to assemble a JS object for a new port.
JsPlumb offers two methods for adding new ports:
addPort(nodeOrGroup:Node|Group, data:Record<string, any>)
and
addNewPort(nodeOrGroup:Node|Group, type:string, portData:Record<string, any>)
The first of these - addPort
- takes some existing data as argument, from which it will derive an ID and type (from the current idFunction
and typeFunction
) and then add the port to the given node or group.
The second function takes the type of the port to add, which it hands off to the port factory in order to get a data object for the new port. As with nodes, if no portFactory is defined then a simple JS object containing an id parameter is created.
Here's the port factory from the Schema builder example application. In this application, each column in a table is mapped to a port:
function(node, type, data, callback) {
let column = {
id:data.columnName,
name:data.columnName[0].toUpperCase() + data.columnName.slice(1),
datatype:"varchar",
type:"column"
};
// add to node's data. we have to do this manually. JsPlumb does not know our internal
// data structure.
node.data.columns.push(column);
// handoff the new column.
callback(column);
}
The signature is slightly different for a port factory: it is given the node/group to which the port should be added, and it is not given a browser event or the isNative
flag.
addNewPort
takes three values:
- The node to add the port to (either an ID or a node itself)
- The type of port to add
- A JS object containing data for the new port.
Edge Factory
The edge factory is the function that JsPlumb will call whenever a new edge is established between two nodes/ports, via user mouse action. It follows the same pattern as nodes and ports - if there is no edge factory defined then a default JS object will be used. The edge factory is only ever called when the user creates a new edge with the mouse. It is not called if you make a programmatic call toaddEdge
.
Edge factory context
The edge factory is passed a context
value as the 4th argument to the function. It contains the following:
{
sourceNodeId : ID of the node that is the source of the edge (or the node on which the source port resides)
sourcePortId : ID of the port that is the source of the edge. Null when the source is not a port.
targetNodeId : ID of the node that is the target of the edge (or the node on which the target port resides)
targetPortId : ID of the port that is the target of the edge. Null when the target is not a port.
type : Type of the edge being created
source : The edge source - a node, group or port.
target : The edge target - a node, group or port.
sourceId : Full ID of the edge source. When the source is a port this will be in dotted (node.port) format
targetId : Full ID of the edge target. When the target is a port this will be in dotted (node.port) format
sourceType : "Group", "Node", or "Port". This is fact just the `objectType` property from the value of `source`.
targetType : "Group", "Node", or "Port". This is fact just the `objectType` property from the value of `target`.
}
Constraining Connectivity
This concept is also discussed in the dragging edges docs, but it's important to note that interceptors discussed here run at the model level, ie. in response both to UI events and also to calls to the programmatic API.
Connectivity can be controlled at runtime by interceptors - callbacks that can be used to cancel some proposed activity, and that are bound on an instance of the Toolkit by supplying a specific function in the Toolkit constructor options. JsPlumb currently supports five interceptors.
beforeConnect
A function to run before an edge with the given data can be established between the given source and target. Returning false from this method aborts the connection. Note that this method fires regardless of the source of the new edge, meaning it will be called when loading data programmatically.
Method signature
beforeConnect(source: Vertex, target: Vertex): any
Parameters
- source The source vertex for the new edge
- target The target vertex for the new edge
Example
Here, we reject connections from any vertex to itself.
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"beforeConnect": (source: Vertex, target: Vertex) => {
return (source !== target)
}
})
const surface = toolkit.render(someElement, {});
beforeMoveConnection
A function to run before an edge of the given type is relocated from its current source or target to a new source or target. Returning false from this method will abort the move.
Method signature
beforeMoveConnection(source: Vertex, target: Vertex, edge: Edge): any
Parameters
- source Candidate source. May be the edge's current source, or may be a new source.
- target Candidate target. May be the edge's current target, or may be a new target.
- edge The edge that is being moved.
The parameters source
and target
reflect the source and target of the edge if the move were to be accepted. So if, for example, your user drags a connection by its target and drops it elsewhere, target
will be the drop target, not the edge's current target, but source
will be the edge's current source. You can access the current source/target via the source
and target
properties of edge
.
Example
Here, we reject moving any edge that has fixed:true
in it backing data:
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"beforeMoveConnection": (source: Vertex, target: Vertex, edge:Edge) => {
return (edge.data.fixed !== true)
}
})
const surface = toolkit.render(someElement, {});
beforeStartConnect
A function to run before an edge of the given type is dragged from the given source (ie. before the mouse starts moving). This interceptor is slightly different to the others in that it's not just a yes/no question: as with the other interceptors, returning false from this method will reject the action, that is in this case it will not allow a connection drag to begin. But you can also return an object from this method, and when you do that, the connection start is allowed, and the object you returned becomes the payload for the new edge.
Method signature
beforeStartConnect(source: Vertex, type: string): any
Parameters
- source The vertex that is the source for the new edge
- type The computed type for this new edge.
Example - reject a connection start
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"beforeStartConnect": (source: Vertex, type:string) => {
return type !== 'not-connectable'
}
})
const surface = toolkit.render(someElement, {});
Example - provide an initial payload
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"beforeStartConnect": (source: Vertex, type:string) => {
return {
type,
message:`initial payload for vertex ${source.id}`
}
}
})
const surface = toolkit.render(someElement, {});
beforeDetach
A function to run before the given edge is detached from the given source vertex. If this method returns false, the detach will be aborted.
Method signature
beforeDetach(source: Vertex, target: Vertex, edge: Edge, isDiscard?: boolean): any
Parameters
- source The source vertex for the edge that is to be detached.
- target The candidate target for the edge - may be null, if the edge is being discarded
- edge The edge that is being detached.
- isDiscard True if the edge is not now connected to a target.
Example
Here, we reject the detach if the target is null, ie. the user is trying to discard the edge, not relocate it.
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"beforeDetach": (source: Vertex, target: Vertex, edge: Edge, isDiscard?: boolean) => {
return target != null
}
})
const surface = toolkit.render(someElement, {});
beforeStartDetach
Method signature
beforeStartDetach(source: Vertex, edge: Edge): any
A function to run before the given edge is detached from the given source vertex. If this method returns false, the detach will be aborted. The difference between this and beforeDetach
is that this method is fired as soon as a user tries to detach an edge from an endpoint in the UI, whereas beforeDetach
allows a user to detach the edge in the UI.
Parameters
- source The source vertex for the edge that the user has started to detach
- edge The edge that the user has started to detach
Example
Here, we reject the detach if the source vertex has doNotDetachEdges:true
in its backing data.
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"beforeDetach": (source: Vertex, edge: Edge) => {
return source.data.doNotDetachEdges !== true
}
})
const surface = toolkit.render(someElement, {});
Example
In this example we provide a beforeStartConnect
and beforeDetach
interceptor to an instance of the Toolkit. The beforeStartConnect
interceptor prevents the user from dragging connections from any vertex whose ID is not an even number. The beforeDetach
interceptor reattaches detached connections whose source ID is not an event number
import { newInstance } from "@jsplumbtoolkit/browser-ui"
const toolkit = newInstance({
"beforeStartConnect": (source, type) => {
// only allow connections from nodes whose
// ID is an even number
return parseInt(source.id, 10) % 2 === 0
},
"beforeDetach": (source, target, edge, isDiscard) => {
// only allow connections to be detached whose
// source ID is an even number
return parseInt(edge.source.id, 10) % 2 === 0
}
})
const surface = toolkit.render(someElement, {});