Skip to main content

Data Model

Introduction#

The core model abstraction in the jsPlumb Toolkit is that of a Graph, or more properly, a Directed Graph, as discussed here:

http://en.wikipedia.org/wiki/Directed_graph

A Graph is a collection of Nodes, Groups, Edges and Ports.

  • 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.
tip

In the Toolkit 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 - Database Visualizer#

As an example of how these data types map to an application - in the Database Visualizer application that ships with the Toolkit - 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

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. An edge may be connected from SomeQuestion.yes - ie the yes port on the node SomeQuestion to SomeAction, or it may be connected from SomeQuestion.no to SomeOtherAction, etc.

Every application using the Toolkit 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, the Toolkit 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.

TOP


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 Database Visualizer application:

{  "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" }  ]}

The Toolkit 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.

TOP


Object IDs#

Every vertex is required to have a unique ID. The Toolkit 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 the Toolkit:

const toolkit = jsPlumbToolkitBrowserUI.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 the Toolkit 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 the Toolkit 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, the Toolkit 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:

const toolkit = jsPlumbToolkitBrowserUIVanilla.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, the Toolkit 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 Toolkit 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 Database Visualizer application.

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 the Toolkit will use by doing this:

const tk = jsPlumbToolkitBrowserUIVanilla.newInstance({  portSeparator:"#"})

TOP


Extracting and Synchronizing Port Data#

When you load data into an instance of the Toolkit and you have nodes/groups that themselves have ports (consider the Database Visualizer demo 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 Database Visualizer, a 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 the Toolkit to recognise these ports when using the load function, you should define a portExtractor when you create an instance of the Toolkit:

const toolkit = jsPlumbToolkitBrowserUIVanilla.newInstance({  ...  portExtractor: (data:ObjectData, parent:Node|Group):Array<ObjectData> => {      return data.columns || [];  },  ...};

Every node/group that is loaded is passed through this function, whose return value must be an array of Javascript objects. These objects are then used to create ports, which are registered against the given node. This example comes from the DatabaseVisualizer demo.

If you're using a library integration such as Angular, React, Vue2 or Vue3, you won't instantiate a Toolkit directly. You'll instead configure a set of Toolkit constructor parameters which you pass to your integration's surface component - see the individual integration documentation for details.

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:

const toolkit = jsPlumbToolkitBrowserUIVanilla.newInstance({  ...  portUpdater:(data:ObjectData, parent:Node|Group, ports:Array<Port>) => {      return Object.assign(data, {          columns:ports.map((p) => p.data)      })  },};

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:

const toolkit = jsPlumbToolkitBrowserUIVanilla.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; })       });   }})

In these situations you can replace both functions by declaring a portDataProperty:

const toolkit = jsPlumbToolkitBrowserUIVanilla.newInstance({    portDataProperty:"columns"});

This will cause the Toolkit 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.

TOP


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 the Toolkit 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:

let toolkit = jsPlumbToolkitBrowserUI.newInstance({    portDataProperty:"columns",    portOrderProperty:"order"})

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 Toolkit declared above, the ports would be reordered by the Toolkit into the order ID, Type, Nullable.

TOP


Object type#

Every object has an associated type. This is an important concept in the Toolkit, 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:

let toolkit = jsPlumbToolkitBrowserUIVanilla.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);  }});

As with ID, omitting either the portTypeFunction or edgeTypeFunction will cause the Toolkit to use whatever function it has determined it should use for deriving node types. Also, as with ID, the Toolkit 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, see here.

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

TOP


Data Factories#

Node Factory#

When you need to add a new Node programmatically, you do so on the Toolkit 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:

toolkitInstance.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 (see the page on Rendering), the Toolkit needs some way of getting the data for a new Node. By default, the Toolkit 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 (again see the page on Rendering).
  • 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. If isNative is set, you might wish to access the dataTransfer 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:

const toolkit = jsPlumbToolkitBrowserUIVanilla.newInstance({    nodeFactory:function(type, data, callback, evt, native) {        callback({            someKey:"aValue",            someArray:[ 1, 2, 3 ]        });    }};
Ajax Callback#

You might do this instead if you want to get your new Node from the server:

const toolkit = jsPlumbToolkitBrowserUIVanilla.newInstance({    nodeFactory:function(type, data, callback, evt, native) {        makeAnAjaxCall({            url:"/get/new/" + type,            success:callback        });    }};
Native Drop#

You might do this if it's a native drop:

const toolkit = jsPlumbToolkitBrowserUIVanilla.newInstance({    nodeFactory:function(type, data, callback, evt, isNative) {        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);    }};
Calling the NodeFactory directly#

You can call the NodeFactory directly using the addFactoryNode method:

toolkit.addFactoryNode("someType");

This will result in a call to the NodeFactory 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 NodeFactory 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:

let toolkit = jsPlumbToolkitBrowserUI.newInstance({    nodeFactory:function(type, data, callback, evt, native) {        var newNode = callback({            someKey:"aValue",            someArray:[ 1, 2, 3 ]        });    }};

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, function(atts, cancelled) {        if (!cancelled) {            callback(atts);        }    });
}

Here, _editNode opens up 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: function (type, data, callback) {        var newNode;        setTimeout(function() {        _editNode(data, true, function(atts, cancelled) {                        if (cancelled && newNode != null) {                tk.removeNode(newNode);            } else {                tk.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 the Toolkit in the groups demonstration that ships with the Toolkit:

let toolkit = jsPlumbToolkitBrowserUI.newInstance({    groupFactory:function(type, data, callback) {        data.title = "Group " + (toolkit.getGroupCount() + 1);        callback(data);    },    nodeFactory:function(type, data, callback) {        data.name = (toolkit.getNodeCount() + 1);        callback(data);    }});

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 the Toolkit 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.

The Toolkit 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 PortFactory from the Database Visualizer example application - remember, 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. the Toolkit does not know our internal  // data structure.  node.data.columns.push(column);  // handoff the new column.  callback(column);   }

Note the signature is slightly different for a PortFactory: 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 four 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 the Toolkit will call whenever a new Connection is established between two Nodes/Ports, via user mouse action. It follows the same pattern as Nodes and Ports - if there is no EdgeFactory defined then a default JS object will be used. The EdgeFactory is only ever called when the user creates a new Edge with the mouse. It is not called if you make a programmatic call tojsPlumbToolkitInstance.addEdge.

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`.}

TOP


Constraining Connectivity#

When you instantiate a Toolkit, there are various interceptor functions that you can provide, to constrain the connectivity in your application. An example (from the Database Visualizer app):

let toolkit = jsPlumbToolkitBrowserUI.newInstance({  ...  beforeConnect:function(source, target, edgeData) {    return source !== target && source.getNode() !== target.getNode();  }});

beforeConnect is called when the user drags and drops a new connection. The DatabaseVisualizer connects Ports; this interceptor checks that the source and target Ports are not the same, and then that the source and target Ports are not on the same Node.

The full set of interceptors you can provide is:

beforeConnect#

This interceptor is called when the user drags and drops a new connection, as well as when connect is called on a Toolkit instance.

beforeConnect(source:Vertex, target:Vertex, edgeData?:Record<string, any>)

beforeConnect is given the source (a Node, Group or Port) and target (a Node, Group or Port) of a newly dragged Edge, as well as, optionally, data associated with the Edge. Data may be associated with an Edge via the beforeStartConnect interceptor.

Returning anything other than true from this method discards the new Edge.

beforeStartConnect#

beforeConnect(source:Vertex, target:Vertex, edgeData?:Record<string, any>)

This is called as the user begins to drag a new Edge, as well as programmatically when the user calls connect on a Toolkit instance. This interceptor behaves slightly differently from the others: returning false, as with the others, will abort the action (in this case, it will abort the new connection drag). Returning true will allow the connection to drag to continue. But you can also return some value other than true, in which case dragging will continue and your return value will become the new Edge's backing data. If you do return some data from this method, it will be passed in to the beforeConnect interceptor as the edgeData parameter.

beforeMoveConnection#

beforeMoveConnection(source:Vertex, target:Vertex, edge:Edge)

This interceptor is called prior to any existing edge being moved (either programmatically or via the mouse). It is passed the source and target of the existing edge, as well as the edge itself, and if it returns anything other than boolean true, the edge move is aborted. If not supplied, the default behaviour of this function is to honor the allowLoopback, allowNodeLoopback and maxConnections parameters on any Node or Port definitions you supplied to this Toolkit via the model.

beforeDetach#

beforeDetach(source:Vertex, target:Vertex, edge:Edge)

This interceptor can be used to override connection detachment from the mouse. The function is given the source and target vertices, as well as the edge, and is expected to return true to confirm that the detach should occur.

beforeStartDetach#

beforeStartDetach(source:Vertex, target:Vertex, edge:Edge)

This interceptor can also be used to override connection detachment from the mouse, but it is distinct from beforeDetach in that this function is called as soon as the user begins to drag. The function is given the source and target vertices, as well as the edge, and is expected to return true to confirm the detach should occur. Any other return value will abort the detach.