Expandable and Collapsible Ports

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)
Screen Shot 2022-04-14 at 1.57.13 PM
Expanded, showing the links and purple port groups
Screen Shot 2022-04-14 at 1.40.40 PM

(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()
                )
            )
        );
    }
}

It will take a while for me to provide a good answer. I believe that the (not-so-simple) Node with an Array (or two) of items describing ports is what you probably want to use.

In the meantime, consider these not-so-simple nodes:
Cable Mapper
Connection Matrix Nodes
Multiple-connection "Forked" Links
These are just to show that it’s possible to create more complicated relationships between more complicated nodes.

[EDIT: updated to fix the vertical alignment of expanded ports]

<!DOCTYPE html>
<html>
<head>
  <title>Tethered, Expandable, Labeled Ports</title>
  <!-- Copyright 1998-2022 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:700px"></div>

  <script src="https://unpkg.com/gojs"></script>
  <script id="code">
const $ = go.GraphObject.make;

const myDiagram =
  $(go.Diagram, "myDiagramDiv",
    {
      layout: $(go.LayeredDigraphLayout),
      "undoManager.isEnabled": true
    });

const EW = 30;
const PW = 13;
const PH = 13;
const PLW = 13;
const PLH = 32;

function portStyle() {
  return { width: PW-1, height: PH-1, fill: "transparent" };
}

PortTemplateSimple =
  $(go.Panel, "Spot",
    $(go.Shape, portStyle(),
      new go.Binding("portId", "id")),
    new go.Binding("position", "itemIndex", simplePosition).ofObject()
  );
function simplePosition(i, panel) {
  if (panel.panel.itemArray.length <= 3) return new go.Point(0, i*PH);
  return new go.Point(0, 0);
}

PortTemplateExpanded =
  $(go.Panel,
    $(go.Shape, portStyle(),
      new go.Binding("portId", "id"),
      new go.Binding("position", "itemIndex", i => new go.Point(0, i*PH)).ofObject()),
    $(go.Shape, { strokeDashArray: [2, 2] },
      new go.Binding("geometry", "itemIndex", indexToGeo).ofObject()),
  );
function indexToGeo(i, shape) {
  const tot = shape.panel.panel.itemArray.length;
  return new go.Geometry().add(new go.PathFigure(0, 0)
          .add(new go.PathSegment(go.PathSegment.Move, EW+PW, tot*PH))
          .add(new go.PathSegment(go.PathSegment.Move, EW+PW, tot*PH/2))
          .add(new go.PathSegment(go.PathSegment.Line, PW, i*PH + PH/2)));
}

PortTemplateExpandedLabeled =
  $(go.Panel,
    $(go.Shape, portStyle(),
      new go.Binding("portId", "id"),
      new go.Binding("position", "itemIndex", i => new go.Point(0, i*PLH)).ofObject()),
    $(go.TextBlock, { alignment: new go.Spot(0.5, 0.5, 0, 1) },
      new go.Binding("text", "id"),
      new go.Binding("position", "itemIndex", i => new go.Point(0, i*PLH + PH + 1)).ofObject()),
    $(go.Shape, { strokeDashArray: [2, 2] },
      new go.Binding("geometry", "itemIndex", indexToGeoL).ofObject()),
  );
function indexToGeoL(i, shape) {
  const tot = shape.panel.panel.itemArray.length;
  return new go.Geometry().add(new go.PathFigure(0, 0)
          .add(new go.PathSegment(go.PathSegment.Move, EW+PLW, tot*PLH))
          .add(new go.PathSegment(go.PathSegment.Move, EW+PLW, tot*PLH/2))
          .add(new go.PathSegment(go.PathSegment.Line, PLW, i*PLH + PH/2)));
}

myDiagram.nodeTemplate =
  $(go.Node, "Spot",
    { locationSpot: go.Spot.Center, locationObjectName: "BODY",
      layoutConditions: go.Part.LayoutStandard & ~go.Part.LayoutNodeSized },
    $(go.Panel, "Table",
      { name: "BODY", alignmentFocusName: "SHAPE" },
      $(go.Shape, { name: "SHAPE", row: 0, width: 50, height: 50, fill: "lightblue", strokeWidth: 0, portId: "" }),
      $(go.TextBlock, { row: 1, stretch: go.GraphObject.Horizontal, maxLines: 1, overflow: go.TextBlock.OverflowEllipsis },
        new go.Binding("text"))
    ),
    $(go.Panel, "Spot",
      { alignment: go.Spot.Left, alignmentFocus: go.Spot.Right },
      $(go.Panel,
        new go.Binding("itemArray", "ports"),
        { itemTemplate: PortTemplateSimple },
        new go.Binding("itemTemplate", "", data => data.expanded
                                                   ? (data.labeled ? PortTemplateExpandedLabeled : PortTemplateExpanded)
                                                   : PortTemplateSimple)
      ),
      $(go.TextBlock, { visible: false, alignment: new go.Spot(0.5, 0.5, 0, 1) },
        new go.Binding("text", "ports", a => a.length.toString()),
        new go.Binding("visible", "", data => !data.expanded && data.ports.length > 3))
    )
  );

  myDiagram.model = $(go.GraphLinksModel,
    {
      linkFromPortIdProperty: "fpid",
      linkToPortIdProperty: "tpid",
      nodeDataArray: [
        {
          key: 1, text: "Just 2",
          ports: [ { id: "a" }, { id: "b" } ]
        },
        {
          key: 2, text: "No Labs", expanded: true,
          ports: [ { id: "a" }, { id: "b" }, { id: "c" } ]
        },
        {
          key: 3, text: "Labels", expanded: true, labeled: true,
          ports: [ { id: "a" }, { id: "b" }, { id: "c" } ]
        },
        {
          key: 4, text: "Collapsed", expanded: false,
          ports: [ { id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }, { id: "e" }, { id: "f" }, { id: "g" } ]
        },
        {
          key: 5, text: "Expanded", expanded: true,
          ports: [ { id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }, { id: "e" }, { id: "f" }, { id: "g" } ]
        }
      ],
      linkDataArray: [
        { from: 1, to: 2, tpid: "a" },
        { from: 1, to: 3, tpid: "c" },
        { from: 1, to: 4, tpid: "c" },
        { from: 1, to: 5, tpid: "e" }
      ]
    });
  </script>
</body>
</html>

produces:

Thanks for the help Walter! This is already significantly better than what we previously had. I’ve been working on integrating your code into our project, and I’m noticing a couple of hiccups. I should have specified this better, but a user can dynamically add or remove ports. I’ve added some buttons to your example to show you what we’re doing:

<html>
<head>
  <title>Tethered, Expandable, Labeled Ports</title>
  <!-- Copyright 1998-2022 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:700px"></div>
 <button onclick='addPortToSelected()'>Add Port to selected node</button>
  <button onclick='addNode()'>Add Node</button>
  <button onclick='expandOrCollapsePorts()'>Expand/Collapse ports on selected node</button>
  <button onclick='layoutAll()'>LayeredDigraphLayout</button>

  <script src="https://unpkg.com/gojs"></script>
  <script id="code">
  function addNode() {
	const node = {
          key: 7, text: "Zeta",
          ports: [], expanded: false
        }
	myDiagram.model.addNodeData(node);
}

function layoutAll() {
	myDiagram.layout.doLayout(myDiagram);
}

function addPortToSelected() {
	const port = {id: "z"};
  const selected = myDiagram.selection.first();   // assuming someone has selected the node base for example purposes
	myDiagram.model.insertArrayItem(selected.data.ports, -1, port);
}

function expandOrCollapsePorts() {
	const selected = myDiagram.selection.first();   // assuming someone has selected the node base for example purposes
  myDiagram.model.setDataProperty(selected.data, 'expanded', !selected.data.expanded);
}
  
const $ = go.GraphObject.make;

const myDiagram =
  $(go.Diagram, "myDiagramDiv",
    {
      layout: $(go.LayeredDigraphLayout),
      "undoManager.isEnabled": true
    });

const EW = 30;
const PW = 13;
const PH = 13;
const PLW = 13;
const PLH = 32;

function portStyle() {
  return { width: PW-1, height: PH-1, fill: "transparent" };
}

PortTemplateSimple =
  $(go.Panel, "Spot",
    $(go.Shape, portStyle(),
      new go.Binding("portId", "id")),
    new go.Binding("position", "itemIndex", simplePosition).ofObject()
  );
function simplePosition(i, panel) {
  if (panel.panel.itemArray.length <= 3) return new go.Point(0, i*PH);
  return new go.Point(0, 0);
}

PortTemplateExpanded =
  $(go.Panel,
    $(go.Shape, portStyle(),
      new go.Binding("portId", "id"),
      new go.Binding("position", "itemIndex", i => new go.Point(0, i*PH)).ofObject()),
    $(go.Shape, { strokeDashArray: [2, 2] },
      new go.Binding("geometry", "itemIndex", indexToGeo).ofObject()),
  );
function indexToGeo(i, shape) {
  const tot = shape.panel.panel.itemArray.length;
  return new go.Geometry().add(new go.PathFigure(0, 0)
          .add(new go.PathSegment(go.PathSegment.Move, EW+PW, tot*PH))
          .add(new go.PathSegment(go.PathSegment.Move, EW+PW, tot*PH/2))
          .add(new go.PathSegment(go.PathSegment.Line, PW, i*PH + PH/2)));
}

PortTemplateExpandedLabeled =
  $(go.Panel,
    $(go.Shape, portStyle(),
      new go.Binding("portId", "id"),
      new go.Binding("position", "itemIndex", i => new go.Point(0, i*PLH)).ofObject()),
    $(go.TextBlock, { alignment: new go.Spot(0.5, 0.5, 0, 1) },
      new go.Binding("text", "id"),
      new go.Binding("position", "itemIndex", i => new go.Point(0, i*PLH + PH + 1)).ofObject()),
    $(go.Shape, { strokeDashArray: [2, 2] },
      new go.Binding("geometry", "itemIndex", indexToGeoL).ofObject()),
  );
function indexToGeoL(i, shape) {
  const tot = shape.panel.panel.itemArray.length;
  return new go.Geometry().add(new go.PathFigure(0, 0)
          .add(new go.PathSegment(go.PathSegment.Move, EW+PLW, tot*PLH))
          .add(new go.PathSegment(go.PathSegment.Move, EW+PLW, tot*PLH/2))
          .add(new go.PathSegment(go.PathSegment.Line, PLW, i*PLH + PH/2)));
}

myDiagram.nodeTemplate =
  $(go.Node, "Spot",
    { locationSpot: go.Spot.Center, locationObjectName: "BODY",
      layoutConditions: go.Part.LayoutStandard & ~go.Part.LayoutNodeSized },
    $(go.Panel, "Table",
      { name: "BODY", alignmentFocusName: "SHAPE" },
      $(go.Shape, { name: "SHAPE", row: 0, width: 50, height: 50, fill: "lightblue", strokeWidth: 0, portId: "" }),
      $(go.TextBlock, { row: 1, stretch: go.GraphObject.Horizontal, maxLines: 1, overflow: go.TextBlock.OverflowEllipsis },
        new go.Binding("text"))
    ),
    $(go.Panel, "Spot",
      { alignment: go.Spot.Left, alignmentFocus: go.Spot.Right },
      $(go.Panel,
        new go.Binding("itemArray", "ports"),
        { itemTemplate: PortTemplateSimple },
        new go.Binding("itemTemplate", "", data => data.expanded
                                                   ? (data.labeled ? PortTemplateExpandedLabeled : PortTemplateExpanded)
                                                   : PortTemplateSimple)
      ),
      $(go.TextBlock, { visible: false, alignment: new go.Spot(0.5, 0.5, 0, 1) },
        new go.Binding("text", "ports", a => a.length.toString()),
        new go.Binding("visible", "", data => !data.expanded && data.ports.length > 3))
    )
  );

  myDiagram.model = $(go.GraphLinksModel,
    {
      linkFromPortIdProperty: "fpid",
      linkToPortIdProperty: "tpid",
      nodeDataArray: [
        {
          key: 1, text: "Just 2",
          ports: [ { id: "a" }, { id: "b" } ]
        },
        {
          key: 2, text: "No Labs", expanded: true,
          ports: [ { id: "a" }, { id: "b" }, { id: "c" } ]
        },
        {
          key: 3, text: "Labels", expanded: true, labeled: true,
          ports: [ { id: "a" }, { id: "b" }, { id: "c" } ]
        },
        {
          key: 4, text: "Collapsed", expanded: false,
          ports: [ { id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }, { id: "e" }, { id: "f" }, { id: "g" } ]
        },
        {
          key: 5, text: "Expanded", expanded: true,
          ports: [ { id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }, { id: "e" }, { id: "f" }, { id: "g" } ]
        }
      ],
      linkDataArray: [
        { from: 1, to: 2, tpid: "a" },
        { from: 1, to: 3, tpid: "c" },
        { from: 1, to: 4, tpid: "c" },
        { from: 1, to: 5, tpid: "e" }
      ]
    });
  </script>
  </body>
</html>

Screen Shot 2022-04-15 at 1.40.07 PM

This works fairly well, but adding expanded ports on the fly seems to shift the previous port “links” up out of the way. If I collapse and expand them, it seems to correct itself though.
The second issue is in your screenshot above- the ports with labels should have port ‘b’ centered vertically on the node so that its “link” should be perfectly horizontal (so really, all the ports should shift down some there). I was looking at that this afternoon and think it’s the labels shifting the math off a bit.

If it helps simplify the problem, ports should always have some kind of name underneath them when they are expanded. The user can set this name to be an empty string, but the padding and location of the port should remain the same regardless.

Yes, it looks like each port needs to be re-generated so that the corresponding line can be recomputed to adapt to the different number of ports and thus different total height.

Ah, adding in selected.updateTargetBindings(); in my addPortHandler() to update the node like you suggested worked perfectly for cleaning up all of the bugginess. Thanks again Walter!