Port Location Model

Graphs
yFiles
About the confusing port world in yFiles.

Ports within yFiles are a confusing thing for various reasons:

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= []; // fails

How 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 createEdge you need to set:
 graphComponent.graph.nodeDefaults.ports.locationParameter = an instance of an IPortLocationModel

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