Hello!
I’ve been working on this project for a while now and figured it’s time to consult with the experts on the best way of handling our desired behavior. For background, we are getting ready to rewrite our application in React and are taking this opportunity to explore if there’s a better way to implement our expandable/collapsible ports, as they are a bit of a pain right now to work with.
Collapsed:
Expanded with descriptions (minus the faux links needed)
Expanded, showing the links and purple port groups
(I’ll explain the purple squares/ extra colors in the pictures below.)
Behavior: When a user adds a node, it starts off as a simple square. As they add input or output ports to the node, they should add starting at the center left or right of the node and expand such that the array of ports is always centered on the node. Once there are more than three ports added to the node, we give the user a collapsed view of a singular port with the number of how many ports are added on that collapsed view. All the connections that were going to the ports before should point to this collapsed port. If the user wants to see all of their ports and descriptions about the ports, they can expand them. The ports can be expanded at any time, whether there’s 1 or 10 added to the node. When expanded, there should be dotted lines connecting the side of the ports to the node itself.
Question time: I have two general ways of implementing this, each with their own caveats. The first approach I took was using an item array to hold the ports. I can achieve the positioning of the ports fairly easy this way; however, after researching I’m not sure of a way to dynamically create “links” (or rather just line shapes pretending to be links) between the center sides of the expanded ports and the node itself. The other issue I’ll have to work around is port selection- I know you can edit the style of a port (like here), but I believe I’ll need to implement some custom mouse tool to detect the port should be unselected if the graph or any other item is selected. The real problem I have is getting those faux connections between the ports and the nodes, but I wanted to provide a little more insight into what our expected behavior is.
The second way I’ve attempted implementing this is with groups. Given the ports need to be selectable like nodes and collapsible like groups, it made more sense to me to implement it this way. I treat each port as it’s own ‘GoJS Node’ and I have the square body of our node built into a group template. So the blue square body is a Group, that contains two additional port Groups for input and output, which then each individually contain the port acting as GoJS Nodes. The code attached below may help explain this too.
What I’m struggling with this approach is getting the layout of the ports to be at the center sides of the node. I was able to get the port layout in it’s respective port group to behave how I want from here, but I cannot seem to adjust the port groups to spawn perfectly in the middle of the side of the node. That is what the purple squares are in the above pictures, the port groups, and they wont ever actually be seen in the graph, they’re just there for debugging purposes.
Things to also consider:
- The graph does not allow cycles. With the port group method, I think the links between the ports to the blue square must always be there, possibly just transparent or invisible, so using diagram → validCycle: Diagram.CycleNotDirected should still work
- Automatic layouts: we let the user add and arrange nodes however they like on the graph, but they can press a button that will clean up the graph for them into a LayeredDigraph layout. This was a huge pain point for our current implementation which is sort of a hybrid approach to my two approaches above. We need them to be able to sort the nodes, and the ports stay where they are on the node as the node is arranged
- The ports cannot move by the user, only showing in the expanded or collapsed states. If the node moves, they must move in the same position along with the node
- We can have graphs with 1000’s of nodes in it, although not extremely common, performance is still a concern
- Adding or removing ports should not shift the node in any way
Sorry for the novel of a question, I’ve tried to do my research and seem to be hitting walls with each approach. Any advice would be greatly appreciated, thanks!
ItemArray approach:
class NodeTemplate {
public static squareTemplate(handlers: Map<string, Function>): Node {
const GO = GraphObject.make;
return GO(
Node,
'Table', // the Shape will go around the TextBlock
{
locationObjectName: 'BODY',
selectionObjectName: 'BODY',
locationSpot: Spot.Center,
contextMenu: AdornmentTemplate.nodeMenu(),
},
new Binding('location', 'loc'),
GO(
Panel,
'Auto',
{
row: 1,
column: 0,
background: 'pink',
// rowSpan: 3,
},
PortTemplate.createExpandablePortGroup(true, handlers)
),
GO(
Panel,
'Spot',
{
row: 1,
column: 1,
name: 'BODY',
background: 'green',
},
this.squarePanel(handlers)
// this.nodeLabel()
)
// GO(Panel, 'Auto', { row: 2, column: 1, background: 'red' }, this.nodeLabel())
);
}
public static squarePanel(handlers: Map<string, Function>): Panel {
const GO = GraphObject.make;
return GO(
Panel,
'Spot',
{},
GO(
Shape,
'RoundedRectangle',
{
name: 'BODY',
isPanelMain: true,
width: Constants.NODE_WIDTH_AND_HEIGHT,
height: Constants.NODE_WIDTH_AND_HEIGHT,
strokeWidth: 0,
cursor: 'pointer',
parameter1: 1 /*cornerRoundness*/,
fromSpot: Spot.RightCenter,
toSpot: Spot.LeftCenter,
portId: '',
fromEndSegmentLength: 0,
toEndSegmentLength: 0,
margin: 5,
},
new Binding('fill', 'color')
)
);
}
public static nodeLabel(): TextBlock {
const GO = GraphObject.make;
return GO(
TextBlock,
{
// alignment: new Spot(0.5, 1, 0, 10),
alignment: Spot.BottomCenter,
font: '400 .875rem Roboto, sans-serif',
},
new Binding('text', 'text')
);
}
}
class PortTemplate {
public static port(isInput: boolean, expanded: boolean, handlers: Map<string, any>): Panel {
const GO = GraphObject.make;
return GO(
Panel,
'Spot',
{
_side: isInput ? 'input' : 'output',
fromSpot: isInput ? Spot.Right : Spot.Left,
toSpot: isInput ? Spot.Left : Spot.Right,
fromLinkable: !isInput,
toLinkable: isInput,
toMaxLinks: 1,
cursor: 'pointer',
margin: expanded ? 10 : 2,
},
// new Binding('location', 'loc').makeTwoWay(),
new Binding('portId', 'key'),
GO(
Shape,
'Rectangle',
{
stroke: 'black',
fill: 'transparent',
desiredSize: new Size(13, 13),
},
new Binding('fill', 'portColor')
),
GO(TextBlock, {
name: 'PORT_TEXT',
font: '350 .875rem Roboto, sans-serif',
// margin: 2,
alignment: new Spot(0.5, 1, 0, 10),
text: 'hello',
visible: expanded,
overflow: TextBlock.OverflowEllipsis,
// stretch: GraphObject.Horizontal,
}),
); // end itemTemplate
}
public static collapsedPort(isInput: boolean, handlers: Map<string, any>): Panel {
const GO = GraphObject.make;
return GO(
Panel,
this.port(isInput, false, handlers),
GO(TextBlock, {
name: 'COLLAPSED_PORT_TEXT',
font: '350 .875rem Roboto, sans-serif',
margin: 2,
})
); // end itemTemplate
}
public static expandedPorts(isInput: boolean, handlers: Map<string, any>): Panel {
const GO = GraphObject.make;
return GO(
Panel,
{
margin: new Margin(0, 15, 0, 0),
},
this.port(isInput, true, handlers)
); // end itemTemplate
}
public static createExpandablePortGroup(isInput: boolean, handlers: Map<string, any>): Panel {
const GO = GraphObject.make;
return GO(
Panel,
'Vertical',
/** Default looking ports, when there are 3 or less showing */
GO(
Panel,
'Vertical',
new Binding('itemArray', isInput ? 'inputPorts' : 'outputPorts'),
{
itemTemplate: this.port(isInput, false, handlers),
},
new Binding('visible', isInput ? 'inputPortPosition' : 'outputPortPosition', (value: PortPosition) => {
return value === PortPosition.Default;
})
),
/** Collapsed ports, when there are more than 3 showing */
GO(
Panel,
'Vertical',
this.collapsedPort(isInput, handlers),
new Binding('visible', isInput ? 'inputPortPosition' : 'outputPortPosition', (value: PortPosition, node: Node) => {
return value === PortPosition.Collapsed;
})
),
/** Expanded ports, when the user wants to look at the ports and all their details */
GO(
Panel,
'Vertical',
new Binding('itemArray', isInput ? 'inputPorts' : 'outputPorts'),
{
itemTemplate: this.expandedPorts(isInput, handlers),
},
new Binding('visible', isInput ? 'inputPortPosition' : 'outputPortPosition', (value: PortPosition) => {
return value === PortPosition.Expanded;
})
)
);
}
}
Group implementation templates:
class NodeTemplate {
public static nodeGroupTemplate(handlers: Map<string, Function>): Group {
const GO = GraphObject.make;
return GO(
Group,
'Vertical',
{
locationSpot: Spot.Center,
locationObjectName: 'BODY',
selectionObjectName: 'BODY',
background: 'orange',
contextMenu: AdornmentTemplate.nodeMenu(),
layout: GO(GridLayout),
},
new Binding('location', 'loc').makeTwoWay(),
this.squarePanel(handlers),
this.nodeLabel()
);
}
public static squarePanel(handlers: Map<string, Function>): Panel {
const GO = GraphObject.make;
return GO(
Panel,
'Spot',
{},
GO(
Shape,
'RoundedRectangle',
{
name: 'BODY',
isPanelMain: true,
width: Constants.NODE_WIDTH_AND_HEIGHT,
height: Constants.NODE_WIDTH_AND_HEIGHT,
cursor: 'pointer',
parameter1: 1 /*cornerRoundness*/,
fromSpot: Spot.RightCenter,
toSpot: Spot.LeftCenter,
portId: '',
fromEndSegmentLength: 0,
toEndSegmentLength: 0,
},
new Binding('fill', 'color')
)
);
}
public static nodeLabel(): TextBlock {
const GO = GraphObject.make;
return GO(
TextBlock,
{
// alignment: new Spot(0.5, 1, 0, 5),
margin: new Margin(8, 0, 0, 0),
font: '400 .875rem Roboto, sans-serif',
},
new Binding('text', 'text')
);
}
}
class PortTemplate {
public static port(isInput: boolean, handlers: Map<string, any>): Node {
const GO = GraphObject.make;
return GO(
Node,
{
_side: isInput ? 'input' : 'output',
locationSpot: Spot.Center,
locationObjectName: 'PORT',
selectionObjectName: 'PORT',
fromSpot: isInput ? Spot.Right : Spot.Left,
toSpot: isInput ? Spot.Right : Spot.Left,
fromLinkable: !isInput,
toLinkable: isInput,
toMaxLinks: 1,
cursor: 'pointer',
movable: false,
fromEndSegmentLength: 0,
toEndSegmentLength: 0,
},
new Binding('location', 'loc').makeTwoWay(),
new Binding('portId', 'key'),
GO(
Shape,
'Rectangle',
{
stroke: 'black',
fill: 'transparent',
desiredSize: new Size(12, 12),
},
new Binding('fill', 'portColor')
)
); // end itemTemplate
}
public static createExpandablePortGroup(diagram: Diagram, handlers: Map<string, any>): Group {
const GO = GraphObject.make;
return GO(
Group,
'Vertical',
{
locationSpot: Spot.Center,
layout: GO(CenteringVerticalGridLayout),
memberAdded: (thisGroup: Group, partAdded: Part) => {
if (partAdded.category === LinkType.Faux) {
return;
}
const ports: GoJSIterator<Part> = thisGroup.memberParts.filter((port: Part) => {
return port.data.category === PortCategory.Input || port.data.category === PortCategory.Output;
});
const numberOfPorts: number = ports.count;
// TODO: add in expanded lock flag
if (numberOfPorts > Constants.DEFAULT_ROLLUP_PORT_COUNT)
diagram.model.setDataProperty(thisGroup.data, 'portPosition', PortPosition.Collapsed);
const textBlock: TextBlock = thisGroup.findObject('NODE_LABEL') as TextBlock;
textBlock.text = numberOfPorts.toString();
},
memberRemoved: (thisGroup: Group, partAdded: Part) => {
// TODO: care about port removals
},
background: 'purple',
movable: false,
selectable: false,
},
new Binding('location', 'loc').makeTwoWay(),
new Binding('isSubGraphExpanded ', 'portPosition', (value: PortPosition, group: Group) => {
if (value === PortPosition.Collapsed) {
diagram.commandHandler.collapseSubGraph(group);
} else {
diagram.commandHandler.expandSubGraph(group);
}
return value;
}),
GO(
Panel,
'Auto',
{},
GO(
Shape,
'Rectangle',
{
// collapsed port shape
fill: 'transparent',
stroke: 'black',
desiredSize: new Size(12, 12),
},
new Binding('visible', 'portPosition', (value: PortPosition, group: Group) => {
return value === PortPosition.Collapsed;
})
),
GO(
TextBlock, // the number of ports when the group is collapsed
{
name: 'NODE_LABEL',
},
new Binding('visible', 'isSubGraphExpanded', (value: boolean) => {
return !value;
}).ofObject()
)
)
);
}
}