Data Model / Overview

Data Model

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.
  • Ports are points on your Nodes that are the endpoint of some relationship with another Node, or with a Port on another Node.
  • Edges are relationships between Nodes, Groups or Ports.
  • Groups are collections of Nodes.
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 in a database schema
  • Ports are columns on a table
  • Edges are relationships between columns on two tables
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.

Neither of these applications make use of the concept of Groups, which are new in version 1.1.0. We're currently evaluating a few different options for a demonstration application that uses Groups.

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, Ports and Edges, the Toolkit has the concept of a Path: an in-order list of Nodes/Ports and Edges that represent the path from one Node or Port to some other Node or Port. These are a very useful way of querying, and operating on, your model. Paths are discussed in a separate page.

TOP


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":"id", "datatype":"integer", "primaryKey":true },
    { "id":"isbn", "datatype":"varchar" },
    { "id":"title", "datatype":"varchar" }
  ]
}

The Toolkit has its own internal representation of Nodes, Edges and Ports, 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":"id", "datatype":"integer", "primaryKey":true },
      { "id":"isbn", "datatype":"varchar" },
      { "id":"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


Every node/group is required to have a unique ID. The Toolkit attempts to derive this automatically from your data, by looking for an id member - this is why in the above example the Toolkit's ID is the same as the ID from the original data. Well, it's not exactly the same - the Toolkit converts all IDs into Strings internally.

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:

let toolkit = jsPlumbToolkit.newInstance({
  idFunction:function(data) {
    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, though, the Toolkit converts the ID you supply into a String for internal use.

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.

Edges are not required to have an ID, but you might find it useful to supply one. If you do not supply an ID, the Toolkit will assign one automatically.

You can also supply separate functions to use for extracting the ID for a Port or Edge from some backing data:

let toolkit = jsPlumbToolkit.newInstance({
  idFunction:function(data) {
    return SomeCustomComputing(data);
  },
  portIdFunction:function(data) {
    return PortComputations(data);
  },
  edgeIdFunction:function(data) {
    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 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.

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.

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:

let tk = jsPlumbToolkit.newInstance({
  portSeparator:"#"
});

TOP


When you load data into an instance of the Toolkit and you have Nodes 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":"name", "type":"varchar" },
    { "id":"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 in the arguments to the jsPlumbToolkit.getInstance call:

let toolkit = jsPlumbToolkit.getInstance({
  ...
  portExtractor: function (data, node) {
      return data.columns || [];
  },
  ...
};

Every Node 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 Database Visualizer demo.

A further consideration with the setup discussed above is how to go about keeping the original data, now stored in the Node, 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:

let toolkit = jsPlumbToolkit.getInstance({
  ...
  portUpdater:function(data, node, ports) {
      return jsPlumb.extend(data, {
          columns:jsPlumbToolkitUtil.map(ports, function(p) { return p.data; })
      });
  },
};

data is the Node's current data. node is the Node itself, and ports is an array of Port objects. This example, from the original version of the Database Visualizer demo (which now uses the portDataProperty described below as it is a simple use case), uses a couple of jsPlumb util functions to return the current Node data, with a new value for columns, consisting of the backing data for each of the Node's Ports.

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:

let toolkit = jsPlumbToolkit.getInstance({
    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:

let toolkit = jsPlumbToolkit.getInstance({
    portDataProperty:"columns"
});

This will cause the Toolkit to create the portExtractor and portUpdater functions shown above. Note that this mechanism currently 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. Future versions of the Toolkit will likely support a way to declare port data on a per node type basis.

TOP


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 = jsPlumbToolkit.getInstance({
    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


You can associate a type with each of your Nodes, Groups, Edges and Ports. 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 Node/Edge/Port ID, the type of a Node, Group, Edge or Port 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 = jsPlumbToolkit.newInstance({
  idFunction:function(data) {
    return SomeCustomComputing(data);
  },
  typeFunction:function(data) {
    return SomeOtherComputing(data);
  },
  edgeTypeFunction:function(data) {
    return EdgeTypeComputing(data);
  },
  portTypeFunction:function(data) {
    return PortTypeComputing(data);
  }
});

As with ID, omitting either the Port or Edge type functions 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 Node, Group, Port or Edge 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


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 a NodeFactory in order to gain control over the creation of the data.

A NodeFactory is a function with this signature:

function myNodeFactory (type, data, callback, originalEvent, isNative)
  • type is the type of Node to create.
  • data is the data appropriate to the given Node 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 Node 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 Node 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:

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

You might do this instead if you have jQuery in your page and you want to get your new Node from the server:

let toolkit = jsPlumbToolkit.getInstance({
    nodeFactory:function(type, data, callback, evt, native) {
        $.ajax({
            url:"/get/new/" + type,
            success:callback
        });
    }
};
Native Drop

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

let toolkit = jsPlumbToolkit.getInstance({
    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);
    }
};

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

Since 1.6.14, 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 = jsPlumbToolkit.getInstance({
    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.

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 = jsPlumbToolkit.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.

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 = function(nodeOrGroup, data) { ... }

and

addNewPort = function(nodeOrGroup, type, portData) { ... }

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 only 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 to which the Port should be added, and it is not given a browser event or the isNative flag.

Where does data come from in the code snippet above? In the Database Visualizer app, we use event delegation to listen to clicks on a certain button, then popup a dialog requesting input from the user. If the user enters data and presses OK, we inform the Toolkit via the addNewPort method:

// add new column to table
jsPlumb.on(document, "tap", ".new-column i", function() {
  let info = renderer.getObjectInfo(this); // getObjectInfo is a helper method that retrieves the node or port associated with some element in the DOM.

  jsPlumbToolkit.Dialogs.show({
    id:"dlgName",
    title:"Enter new column name:",
    onOK:function(data) {
    // if the user supplied a column name, tell the toolkit to add a new port. This will result in a callback to the portFactory defined above.
    if (data.name) {
      if (data.name.length < 3)       
        alert("Column names must be at least 3 characters!")
      else
        toolkit.addNewPort(info.id, "column", { id:data.name.toLowerCase(), columnName:data.name });
      }
    }
  });
});

The dialog code shown in this snippet is discussed here.

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.

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.

None of the examples that ship with the Toolkit use the EdgeFactory, but the Database Visualizer contains this stub:

edgeFactory : function(params, data, callback, context) {
  // you must hit the callback if you provide the edgeFactory.
  callback(data);
  // unless you want to return false, to abandon the edge
  //return false;
}

Here, we could manipulate the contents of data before we hit the callback if we wanted to.

Edge factory context

From version 2.1.0 onwards 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


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 = jsPlumbToolkit.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:

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 is given the source (a Node or Port) and target (a Node 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.

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.

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.

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

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 (source, target, edge) and is expected to return true to confirm the detach should occur. Any other return value will abort the detach.