Port Location Model
Ports within yFiles are a confusing thing for various reasons:
- out of the box, yFiles takes care of ports unless you wish to have a particular port design. This is fine, but once you add your own port there seems to always be a central port present, especially if you layout things.
- the ports showing up when creating interactively new connections are not necessarily the ports which show up when using e.g. the GraphBuilder
- layout affects ports and it requires care to keep ports the way you want them
- upon grouping and folding ports get added dynamically
- you can create an edge from a node to another and there seems to be a central port but in the model there isn’t. The central default port shows up visually but does not exist on a lower level.
- there are various classes you need to use but it’s a jungle to figure out what is what.
Defaults
Let’s first consider a simple graph created manually or via the GraphBuilder. In this case the default central connector will be used. Unless! Unless you specify something like
graphComponent.graph.nodeDefaults.ports.locationParameter =...in which case that one will be used instead. Not sure this is mentioned in the documentation. Another confusion here: the default is not necessarily the same as the port candidates when dragging new edges manually.
If you try to clear the ports in some way, it’s not possible:
graphComponent.graph.nodeDefaults.ports.clear(); // fails
graphComponent.graph.nodeDefaults.ports= []; // failsHow to tell there should be no port by default?
Port candidates
A candidate comes via a candidate provider. When you hover over a node you see some ports and this is where the provider comes in. Now, even though you can explicitly attach an edge to a (predefined) port, this port can be shifted by layout unless you enforce it via sourcePortCandidates in the layout data (more below).
To be clear:
- if you create a graph and do not wish to have the default central port used when using
createEdgeyou need to set:
graphComponent.graph.nodeDefaults.ports.locationParameter = an instance of an IPortLocationModelThe FreeNodePortLocationModel would be a typical choice, see here. Again, these are not fixed locations when applying a layout.
- if you wish to use a particular set of ports when creating edges manually you need:
graphComponent.graph.decorator.nodes.portCandidateProvider.addFactory(...) Custom Node Port Location Model
The port location mechanics consists of a model and a set of paramters for this model. In essence, the model allows certain locations and the parameters paramterize the locations.
Having a custom model is useful for multiple reasons:
- you can give the port location names fitting the business context
- you can finetune the design of the ports and have full control.
The IPortLocationModel is the interface you need to implement and an example is below.
export class CustomNodePortLocationModel extends BaseClass(IPortLocationModel) {
inset
constructor(inset = 0) {
super()
this.inset = inset
}
lookup(type) {
return null
}
getLocation(port, locationParameter) {
if (
locationParameter instanceof CustomNodePortLocationModelParameter &&
port.owner instanceof INode
) {
// If we have an actual owner node and the parameter can be really used by this model,
// we just calculate the correct location, based on the node's layout.
const modelParameter = locationParameter
const ownerNode = port.owner
const layout = ownerNode.layout
switch (modelParameter.location) {
case PortLocation.CENTER:
return layout.center
case PortLocation.TOP:
return layout.topLeft.add(layout.topRight).multiply(0.5).add(new Point(0, this.inset))
case PortLocation.BOTTOM:
return layout.bottomLeft
.add(layout.bottomRight)
.multiply(0.5)
.add(new Point(0, -this.inset))
case PortLocation.RIGHT:
return layout.topRight
.add(layout.bottomRight)
.multiply(0.5)
.add(new Point(-this.inset, 0))
case PortLocation.LEFT:
return layout.topLeft.add(layout.bottomLeft).multiply(0.5).add(new Point(this.inset, 0))
default:
throw new Error('Unknown PortLocation')
}
} else {
// no owner node (e.g. an edge port), or parameter mismatch - return (0,0)
return Point.ORIGIN
}
}
createParameter(owner, location) {
if (owner instanceof INode) {
const ownerNode = owner
// determine the distance of the specified location to the node layout center
const delta = location.subtract(ownerNode.layout.center)
if (delta.vectorLength < 0.25 * Math.min(ownerNode.layout.width, ownerNode.layout.height)) {
// nearer to the center than to the border => map to center
return this.createCustomParameter(PortLocation.CENTER)
}
// map to a location on the side
if (Math.abs(delta.x) > Math.abs(delta.y)) {
return this.createCustomParameter(delta.x > 0 ? PortLocation.RIGHT : PortLocation.LEFT)
}
return this.createCustomParameter(delta.y > 0 ? PortLocation.BOTTOM : PortLocation.TOP)
}
// Just return a fallback - getLocation will ignore this anyway if the owner is null or not a node.
return this.createCustomParameter(PortLocation.CENTER)
}
createCustomParameter(location) {
return new CustomNodePortLocationModelParameter(this, location)
}
getContext(_port) {
return ILookup.EMPTY
}
}In essence the logic is about converting from specific names to positions and vice versa.
The parameter class is more about deser than anything else:
export class CustomNodePortLocationModelParameter extends BaseClass(IPortLocationModelParameter) {
owner
location
constructor(owner, location) {
super()
this.owner = owner
this.location = location
}
get model() {
return this.owner
}
clone() {
// we have no mutable state, so return this.
return this
}
static serializationHandler(args) {
// only serialize items that are of the specific type
if (args.item instanceof CustomNodePortLocationModelParameter) {
const modelParameter = args.item
const writer = args.writer
writer.writeStartElement(
'CustomNodePortLocationModelParameter',
'http://orbifold.net/CustomNodePortLocationModelParameter/1.0'
)
writer.writeAttribute('inset', modelParameter.model.inset.toString())
writer.writeAttribute('portLocation', modelParameter.location.toString())
writer.writeEndElement()
// Signal that this item is serialized.
args.handled = true
}
}
static deserializationHandler(args) {
if (args.xmlNode instanceof Element) {
const element = args.xmlNode
if (
element.localName === 'CustomNodePortLocationModelParameter' &&
element.namespaceURI ===
'http://orbifold.net/CustomNodePortLocationModelParameter/1.0'
) {
// setting the result sets the event arguments to handled
const model = new CustomNodePortLocationModel(parseFloat(element.getAttribute('inset')))
args.result = new CustomNodePortLocationModelParameter(
model,
parseInt(element.getAttribute('portLocation'))
)
}
}
}
}Using this in a diagram goes like this:
createGraphComponent() {
const hostDiv = document.getElementsByClassName("graph-host")[0];
this.graphComponent = new GraphComponent(hostDiv);
this.inputMode = new GraphEditorInputMode();
this.graphComponent.inputMode = this.inputMode;
this.inputMode.addEventListener('item-clicked', (evt) => {
if (evt.item instanceof INode) {
evt.handled = true
this.$emit("node-clicked", evt.item.tag.name);
}
})
this.graphComponent.graph.undoEngineEnabled = false;
const model = new CustomNodePortLocationModel(0)
this.graphComponent.graph.nodeDefaults.ports.locationParameter = model.createCustomParameter(PortLocation.TOP)
this.graphComponent.graph.nodeDefaults.style = new ShapeNodeStyle({ shape: 'round-rectangle', fill: 'lightblue', stroke: 'steelBlue', strokeWidth: 1 });
this.graphComponent.graph.decorator.nodes.handleProvider.addFactory(
(node) => new PortsHandleProvider(node)
)
this.graphComponent.graph.decorator.nodes.portCandidateProvider.addFactory(this.getPortCandidateProvider)
}The factory returns ports for a given nodes, for instance:
getPortCandidateProvider(forNode) {
const model = new CustomNodePortLocationModel(0)
return IPortCandidateProvider.fromCandidates([
new PortCandidate(forNode, model.createCustomParameter(PortLocation.RIGHT)),
new PortCandidate(forNode, model.createCustomParameter(PortLocation.LEFT))
])
}This allows you to return ports for a given node. Typically this is also happening in function of layout orientation. For example, a hierarchical top-to-bottom layout looks better with top and bottom ports rather than left and right.
Layout
As said, layout tends to swing port in all directions unless you tell the layout not to. For instance, when using the hierarchical layout this would look like
async layout() {
const layout = new HierarchicalLayout()
layout.defaultMinimumNodeDistance = 80
const layoutData = new HierarchicalLayoutData()
layoutData.ports.sourcePortCandidates = () => {
return new EdgePortCandidates()
.addFixedCandidate("right")
}
layoutData.ports.targetPortCandidates = () => {
return new EdgePortCandidates()
.addFixedCandidate("left")
}
await this.graphComponent.applyLayoutAnimated(layout, "1s", layoutData)
}It’s unclear to me how to force a particular port in this case. How to return a custom parameter telling the layout to use that particular port and no other.
Bonus: GraphBuilder ports
The GraphBuild will use the node default unless you tell the edge creator to do something else. This something else is a custom EdgeCreator class. Everything is a custom class when using yFiles…
With respect to the custom port model above:
class PortAwareEdgeCreator extends EdgeCreator {
constructor(graphBuilder, labelDataFromSourceAndTarget) {
super()
this.$graphBuilder = graphBuilder
this.$labelDataFromSourceAndTarget = labelDataFromSourceAndTarget
}
createEdge(graph, source, target, dataItem) {
const model = new CustomNodePortLocationModel(0)
let sourcePort = source.ports.find(p => p.locationParameter.location === PortLocation.RIGHT);
if (!sourcePort) {
sourcePort = this.$graphBuilder.graph.addPort(source, model.createCustomParameter(PortLocation.RIGHT))
}
let targetPort = target.ports.find(p => p.locationParameter.location === PortLocation.LEFT);
if (!targetPort) {
targetPort = this.$graphBuilder.graph.addPort(target, model.createCustomParameter(PortLocation.LEFT))
}
const edge = this.$graphBuilder.graph.createEdge(
{
sourcePort: sourcePort,
targetPort: targetPort
}
)
return edge
}
}which you need to assign to the edgeSource.edgeCreator like so:
const graphBuilder = new GraphBuilder(this.graphComponent.graph)
const nodeSource = graphBuilder.createNodesSource(g.nodes, (u) => u.id)
const edgeSource = graphBuilder.createEdgesSource(g.edges, (e) => e.source, (e) => e.target)
edgeSource.edgeCreator = new PortAwareEdgeCreator(graphBuilder);
nodeSource.nodeCreator.addEventListener('node-created', (evt) => {
nodeSource.nodeCreator.updateTag(evt.graph, evt.item, { name: `Node ${evt.item.tag.id}` })
})
graphBuilder.buildGraph()Full source
import {
GraphComponent, NodeStyleBase, BaseClass,
IPortCandidateProvider,
PortCandidate,
Point,
ILookup,
INode,
EdgeCreator,
EdgePortCandidates,
IPort,
IPortLocationModel,
IPortLocationModelParameter,
IPortOwner,
PortsHandleProvider
} from '/public/yfiles/yfiles.js'
import { License } from '/public/yfiles/lang.js'
import { HierarchicalLayout, HierarchicalLayoutData } from '/public/yfiles/layout.js'
import { ShapeNodeStyle, GraphBuilder, GraphViewerInputMode, SvgVisual, GraphEditorInputMode } from '/public/yfiles/view.js'
/**
* Enum for port locations on a node.
* Specific to the CustomNodePortLocationModel.
*/
export const PortLocation = Object.freeze({
CENTER: 0,
TOP: 1,
RIGHT: 2,
BOTTOM: 3,
LEFT: 4
})
export class CustomNodePortLocationModel extends BaseClass(IPortLocationModel) {
inset
constructor(inset = 0) {
super()
this.inset = inset
}
lookup(type) {
return null
}
getLocation(port, locationParameter) {
if (
locationParameter instanceof CustomNodePortLocationModelParameter &&
port.owner instanceof INode
) {
// If we have an actual owner node and the parameter can be really used by this model,
// we just calculate the correct location, based on the node's layout.
const modelParameter = locationParameter
const ownerNode = port.owner
const layout = ownerNode.layout
switch (modelParameter.location) {
case PortLocation.CENTER:
return layout.center
case PortLocation.TOP:
return layout.topLeft.add(layout.topRight).multiply(0.5).add(new Point(0, this.inset))
case PortLocation.BOTTOM:
return layout.bottomLeft
.add(layout.bottomRight)
.multiply(0.5)
.add(new Point(0, -this.inset))
case PortLocation.RIGHT:
return layout.topRight
.add(layout.bottomRight)
.multiply(0.5)
.add(new Point(-this.inset, 0))
case PortLocation.LEFT:
return layout.topLeft.add(layout.bottomLeft).multiply(0.5).add(new Point(this.inset, 0))
default:
throw new Error('Unknown PortLocation')
}
} else {
// no owner node (e.g. an edge port), or parameter mismatch - return (0,0)
return Point.ORIGIN
}
}
createParameter(owner, location) {
if (owner instanceof INode) {
const ownerNode = owner
// determine the distance of the specified location to the node layout center
const delta = location.subtract(ownerNode.layout.center)
if (delta.vectorLength < 0.25 * Math.min(ownerNode.layout.width, ownerNode.layout.height)) {
// nearer to the center than to the border => map to center
return this.createCustomParameter(PortLocation.CENTER)
}
// map to a location on the side
if (Math.abs(delta.x) > Math.abs(delta.y)) {
return this.createCustomParameter(delta.x > 0 ? PortLocation.RIGHT : PortLocation.LEFT)
}
return this.createCustomParameter(delta.y > 0 ? PortLocation.BOTTOM : PortLocation.TOP)
}
// Just return a fallback - getLocation will ignore this anyway if the owner is null or not a node.
return this.createCustomParameter(PortLocation.CENTER)
}
createCustomParameter(location) {
return new CustomNodePortLocationModelParameter(this, location)
}
getContext(_port) {
return ILookup.EMPTY
}
}
export class CustomNodePortLocationModelParameter extends BaseClass(IPortLocationModelParameter) {
owner
location
constructor(owner, location) {
super()
this.owner = owner
this.location = location
}
get model() {
return this.owner
}
clone() {
// we have no mutable state, so return this.
return this
}
static serializationHandler(args) {
// only serialize items that are of the specific type
if (args.item instanceof CustomNodePortLocationModelParameter) {
const modelParameter = args.item
const writer = args.writer
writer.writeStartElement(
'CustomNodePortLocationModelParameter',
'http://orbifold.net/CustomNodePortLocationModelParameter/1.0'
)
writer.writeAttribute('inset', modelParameter.model.inset.toString())
writer.writeAttribute('portLocation', modelParameter.location.toString())
writer.writeEndElement()
// Signal that this item is serialized.
args.handled = true
}
}
static deserializationHandler(args) {
if (args.xmlNode instanceof Element) {
const element = args.xmlNode
if (
element.localName === 'CustomNodePortLocationModelParameter' &&
element.namespaceURI ===
'http://orbifold.net/CustomNodePortLocationModelParameter/1.0'
) {
// setting the result sets the event arguments to handled
const model = new CustomNodePortLocationModel(parseFloat(element.getAttribute('inset')))
args.result = new CustomNodePortLocationModelParameter(
model,
parseInt(element.getAttribute('portLocation'))
)
}
}
}
}
// this is specific to the GraphBuilder and is not necessary if you create the graph manually
class PortAwareEdgeCreator extends EdgeCreator {
constructor(graphBuilder, labelDataFromSourceAndTarget) {
super()
this.$graphBuilder = graphBuilder
this.$labelDataFromSourceAndTarget = labelDataFromSourceAndTarget
}
createEdge(graph, source, target, dataItem) {
const model = new CustomNodePortLocationModel(0)
let sourcePort = source.ports.find(p => p.locationParameter.location === PortLocation.RIGHT);
if (!sourcePort) {
sourcePort = this.$graphBuilder.graph.addPort(source, model.createCustomParameter(PortLocation.RIGHT))
}
let targetPort = target.ports.find(p => p.locationParameter.location === PortLocation.LEFT);
if (!targetPort) {
targetPort = this.$graphBuilder.graph.addPort(target, model.createCustomParameter(PortLocation.LEFT))
}
const edge = this.$graphBuilder.graph.createEdge(
{
sourcePort: sourcePort,
targetPort: targetPort
}
)
return edge
}
}
export default {
graphComponent: null,
template: `
<div class="graph-host"></div>
`,
data() {
return {
};
},
methods: {
getPortCandidateProvider(forNode) {
const model = new CustomNodePortLocationModel(0)
// noinspection JSCheckFunctionSignatures
return IPortCandidateProvider.fromCandidates([
// new PortCandidate(forNode, model.createCustomParameter(PortLocation.CENTER)),
// new PortCandidate(forNode, model.createCustomParameter(PortLocation.TOP)),
new PortCandidate(forNode, model.createCustomParameter(PortLocation.RIGHT)),
// new PortCandidate(forNode, model.createCustomParameter(PortLocation.BOTTOM)),
new PortCandidate(forNode, model.createCustomParameter(PortLocation.LEFT))
])
},
ErdosRenyi(n = 30, p = 0.1) {
const graph = { nodes: [], edges: [] };
let i, j;
for (i = 0; i < n; i++) {
graph.nodes.push({ id: i });
for (j = 0; j < i; j++) {
if (Math.random() < p) {
graph.edges.push({ source: i, target: j });
}
}
}
return graph;
},
async layout() {
const layout = new HierarchicalLayout()
layout.defaultMinimumNodeDistance = 80
const layoutData = new HierarchicalLayoutData()
layoutData.ports.sourcePortCandidates = () => {
return new EdgePortCandidates()
.addFixedCandidate("right")
}
layoutData.ports.targetPortCandidates = () => {
return new EdgePortCandidates()
.addFixedCandidate("left")
}
await this.graphComponent.applyLayoutAnimated(layout, "1s", layoutData)
this.$emit("layout-done", "hierarchic");
},
async loadGraph() {
this.graphComponent.graph.clear()
const g = this.ErdosRenyi();
const graphBuilder = new GraphBuilder(this.graphComponent.graph)
const nodeSource = graphBuilder.createNodesSource(g.nodes, (u) => u.id)
const edgeSource = graphBuilder.createEdgesSource(g.edges, (e) => e.source, (e) => e.target)
edgeSource.edgeCreator = new PortAwareEdgeCreator(graphBuilder);
nodeSource.nodeCreator.addEventListener('node-created', (evt) => {
nodeSource.nodeCreator.updateTag(evt.graph, evt.item, { name: `Node ${evt.item.tag.id}` })
})
graphBuilder.buildGraph()
},
async setLicense() {
try {
// get a yfiles license
} catch (error) {
alert('Error loading license:', error);
}
},
addPort(node, location) {
const model = new CustomNodePortLocationModel(0)
const port = this.graphComponent.graph.addPort(node, model.createCustomParameter(location));
return port
},
async createGraphComponent() {
const hostDiv = document.getElementsByClassName("graph-host")[0];
this.graphComponent = new GraphComponent(hostDiv);
this.inputMode = new GraphEditorInputMode();
this.graphComponent.inputMode = this.inputMode;
this.inputMode.addEventListener('item-clicked', (evt) => {
if (evt.item instanceof INode) {
evt.handled = true
this.$emit("node-clicked", evt.item.tag.name);
}
})
this.graphComponent.graph.undoEngineEnabled = false;
const model = new CustomNodePortLocationModel(0)
this.graphComponent.graph.nodeDefaults.ports.locationParameter = model.createCustomParameter(PortLocation.TOP)
this.graphComponent.graph.nodeDefaults.style = new ShapeNodeStyle({ shape: 'round-rectangle', fill: 'lightblue', stroke: 'steelBlue', strokeWidth: 1 });
// this.graphComponent.graph.decorator.nodes.handleProvider.addFactory(
// (node) => new PortsHandleProvider(node)
// )
this.graphComponent.graph.decorator.nodes.portCandidateProvider.addFactory(this.getPortCandidateProvider)
},
addNode(label) {
const graph = this.graphComponent.graph;
const node = graph.createNode();
graph.addLabel(node, label);
this.layout();
}
},
async mounted() {
await this.setLicense();
await this.createGraphComponent();
await this.loadGraph();
await this.layout();
},
}If you need help, ask us.