Writing a Custom Layout Stage
yFiles for HTML provides many flexible layout algorithms that arrange graph elements in different styles. A multitude of properties and customizable descriptors offer a high degree of flexibility. However, sometimes further customization of the predefined algorithms is necessary. In this case, developers can write a custom layout stage or even a complete layout algorithm.
A layout stage encapsulates arbitrary layout functionality and provides a general way to combine multiple stages into a compound layout process. It is based on the interface ILayoutStage, which extends ILayoutAlgorithm. Like any other ILayoutAlgorithm, its layout is invoked with a call to applyLayout. In addition, it has a coreLayout property, which takes another ILayoutAlgorithm implementation. The applyLayout method of the coreLayout is typically invoked somewhere during the stage’s applyLayout method.
It is recommended to derive a custom layout stage from the class LayoutStageBase and override its method applyLayoutImpl. This approach conveniently checks whether the stage is enabled. If it is not enabled, only the core layout is applied.
yFiles for HTML already provides a large number of layout stages. Even the full-fledged layout styles and edge routers are also implemented as layout stages. Therefore, these layout stages can be combined to realize complex layout processes tailored to your specific requirements.
Common Purposes of a Layout Stage
Layout stages add additional steps to the layout process. Generally, a layout stage performs preprocessing steps on the input graph before the core layout is invoked, and postprocessing steps afterward.
class BasicLayoutStage extends LayoutStageBase {
applyLayoutImpl(graph) {
// preparation
// core layout
this.coreLayout?.applyLayout(graph)
// postprocessing
}
}
class BasicLayoutStage extends LayoutStageBase {
protected applyLayoutImpl(graph: LayoutGraph | null): void {
// preparation
// core layout
this.coreLayout?.applyLayout(graph!)
// postprocessing
}
}
Here are a few examples of how layout stages can be used (this is only a small subset of possible applications): * Temporarily add group nodes or edges to emphasize relations between elements not already represented by the graph structure. * Temporarily hide elements that should not influence the overall layout. * Post-process coordinates, for example, elongate edges from the node’s rectangular bounding box to the real outline of the node.
The Custom Layout Stage demo shows different use cases for custom layout stages.
Example: Decorating a Graph
A typical use case for a custom layout stage is to add or hide some elements. In this example, we hide some edges that do not represent structural information. This is usually achieved in three steps:
- Hide the edges: Temporarily removing these edges from the graph: Hiding Graph Elements.
- Apply the core layout.
- Unhide the edges again. In this example, an edge router is applied exclusively to these edges.
The following example demonstrates how to achieve this.
class EdgeHidingStage extends LayoutStageBase {
// A data provider key for specifying edges which do not provide structural information
static NonStructuralEdgesDataKey = new EdgeDataKey(
'NonStructuralEdgesDataKey'
)
applyLayoutImpl(graph) {
// ------------------------------------------
// Preparation
// ------------------------------------------
if (!graph) {
return
}
const edgesToHide = graph.context.getItemData(
EdgeHidingStage.NonStructuralEdgesDataKey
)
if (edgesToHide === null) {
// no data provider registered: simply run the core layout and return
this.coreLayout?.applyLayout(graph)
return
}
// hide all edges for which the data provider returns true
const hider = new LayoutGraphHider(graph)
hider.hideEdges(
new YList(graph.edges.filter((edge) => edgesToHide.get(edge)))
)
try {
// ------------------------------------------
// Apply the core layout
// ------------------------------------------
this.coreLayout?.applyLayout(graph)
} finally {
// ------------------------------------------
// Clean up
// ------------------------------------------
hider.unhideAll()
// now route all previously hidden edges
// re-use the stage's data provider key
// to tell EdgeRouter which edges to route
const edgeRouter = new EdgeRouter()
const edgeRouterData = edgeRouter.createLayoutData(graph)
edgeRouterData.scope.edges = edgesToHide
edgeRouter.applyLayout(graph)
}
}
}
class EdgeHidingStage extends LayoutStageBase {
// A data provider key for specifying edges which do not provide structural information
static readonly NonStructuralEdgesDataKey = new EdgeDataKey<boolean>(
'NonStructuralEdgesDataKey'
)
protected applyLayoutImpl(graph: LayoutGraph | null): void {
// ------------------------------------------
// Preparation
// ------------------------------------------
if (!graph) {
return
}
const edgesToHide = graph.context.getItemData(
EdgeHidingStage.NonStructuralEdgesDataKey
)
if (edgesToHide === null) {
// no data provider registered: simply run the core layout and return
this.coreLayout?.applyLayout(graph)
return
}
// hide all edges for which the data provider returns true
const hider = new LayoutGraphHider(graph)
hider.hideEdges(
new YList(graph.edges.filter((edge) => edgesToHide.get(edge)!))
)
try {
// ------------------------------------------
// Apply the core layout
// ------------------------------------------
this.coreLayout?.applyLayout(graph)
} finally {
// ------------------------------------------
// Clean up
// ------------------------------------------
hider.unhideAll()
// now route all previously hidden edges
// re-use the stage's data provider key
// to tell EdgeRouter which edges to route
const edgeRouter = new EdgeRouter()
const edgeRouterData = edgeRouter.createLayoutData(graph)
edgeRouterData.scope.edges = edgesToHide
edgeRouter.applyLayout(graph)
}
}
}
Note that it is good practice to put the clean-up step in a finally
block.
In this example, it is assumed that there is an IMapper<K,V> which returns true
for edges
which should be hidden. That mapper is queried from the context using a static key defined by the custom stage,
in this case of type EdgeDataKey<TValue>.
When applying the layout on an IGraph or a GraphComponent the most convenient way to provide data for a
custom data key is to use a GenericLayoutData instance.
This is shown in example Applying the Custom Layout on a GraphComponent:
// Create the layout stage with a hierarchical layout as core layout
const layout = new EdgeHidingStage({ coreLayout: new HierarchicalLayout() })
// Attach the data for edges to hide using GenericLayoutData
const data = new GenericLayoutData()
// Tie the item data using the well-known key defined by the custom stage
const edgesToHide = data.addItemCollection(
EdgeHidingStage.NonStructuralEdgesDataKey
)
// Hide the selected edges
edgesToHide.source = graphComponent.selection.edges
// Now apply the layout
await graphComponent.applyLayoutAnimated(layout, '500ms', data)
// Create the layout stage with a hierarchical layout as core layout
const layout = new EdgeHidingStage({ coreLayout: new HierarchicalLayout() })
// Attach the data for edges to hide using GenericLayoutData
const data = new GenericLayoutData<INode, IEdge, ILabel, ILabel>()
// Tie the item data using the well-known key defined by the custom stage
const edgesToHide = data.addItemCollection(
EdgeHidingStage.NonStructuralEdgesDataKey
)
// Hide the selected edges
edgesToHide.source = graphComponent.selection.edges
// Now apply the layout
await graphComponent.applyLayoutAnimated(layout, '500ms', data)
Example: Pre-Processing
This example demonstrates a pre-processing stage where some nodes are resized before the core layout is executed by coreLayout.
class SizeSettingLayoutStage extends LayoutStageBase {
static SizeDataKey = new NodeDataKey('SizeDataKey')
applyLayoutImpl(graph) {
if (!graph) {
// if there is no graph, nothing to do
return
}
// before the actual layout: adjust the size of the nodes
const sizeProvider = graph.context.getItemData(
SizeSettingLayoutStage.SizeDataKey
)
// only if the data provider is set
if (sizeProvider != null) {
for (const node of graph.nodes) {
// only for nodes which have a value associated
const size = sizeProvider.get(node)
if (size && size > 0) {
node.layout.size = new Size(size, size)
}
}
}
this.coreLayout?.applyLayout(graph)
}
}
class SizeSettingLayoutStage extends LayoutStageBase {
static SizeDataKey = new NodeDataKey<number>('SizeDataKey')
protected applyLayoutImpl(graph: LayoutGraph | null): void {
if (!graph) {
// if there is no graph, nothing to do
return
}
// before the actual layout: adjust the size of the nodes
const sizeProvider = graph.context.getItemData(
SizeSettingLayoutStage.SizeDataKey
)
// only if the data provider is set
if (sizeProvider != null) {
for (const node of graph.nodes) {
// only for nodes which have a value associated
const size = sizeProvider.get(node)
if (size && size > 0) {
node.layout.size = new Size(size, size)
}
}
}
this.coreLayout?.applyLayout(graph)
}
}
In this example, we assume there is a mapper that returns the desired size of a node, using a custom key for registration. For simplicity, the code does not check if the retrieved size is valid (i.e., strictly positive). If no size is associated with the node, the node’s current size remains unchanged.
Providing Data for Custom Layout Stages
Most of the above examples require custom data. If the layout is applied on an IGraph or a GraphComponent as shown in Applying an Automatic Layout, setting up data for custom stages is facilitated using the GenericLayoutData class.
When directly working with the LayoutGraph and applying the layout to it, additional data is added to the graph via its context. To learn how to do this, refer to section Binding Data to Graph Elements.
// an example data key for an ItemCollection:
// the corresponding mapper returns true for each node which is contained in the collection
const includedNodesDataKey = new NodeDataKey('IncludedNodesDataKey')
// an example data key for an ItemMapping:
// the corresponding mapper returns a number for each edge
const edgeWeightDataKey = new EdgeDataKey('EdgeWeightDataKey')
// generate generic data for IGraph elements
const genericData = new GenericLayoutData()
// add an ItemCollection for IncludedNodesDataKey
const includedNodes = genericData.addItemCollection(includedNodesDataKey)
includedNodes.predicate = (node) => 'IncludeMe' === node.tag
// add an ItemMapping for EdgeWeightDataKey
const edgeWeights = genericData.addItemMapping(edgeWeightDataKey)
edgeWeights.mapper.set(edge1, 10)
edgeWeights.mapper.set(edge2, 20)
await graphComponent.applyLayoutAnimated(layout, '0.5s', genericData)
// an example data key for an ItemCollection:
// the corresponding mapper returns true for each node which is contained in the collection
const includedNodesDataKey = new NodeDataKey<boolean>('IncludedNodesDataKey')
// an example data key for an ItemMapping:
// the corresponding mapper returns a number for each edge
const edgeWeightDataKey = new EdgeDataKey<number>('EdgeWeightDataKey')
// generate generic data for IGraph elements
const genericData = new GenericLayoutData<INode, IEdge, ILabel, ILabel>()
// add an ItemCollection for IncludedNodesDataKey
const includedNodes = genericData.addItemCollection(includedNodesDataKey)
includedNodes.predicate = (node: INode) => 'IncludeMe' === node.tag
// add an ItemMapping for EdgeWeightDataKey
const edgeWeights = genericData.addItemMapping(edgeWeightDataKey)
edgeWeights.mapper.set(edge1, 10)
edgeWeights.mapper.set(edge2, 20)
await graphComponent.applyLayoutAnimated(layout, '0.5s', genericData)