Creating Link from/to same node without impacting layout

So I’m trying to get a “fake” link coming off some nodes, that’s just a short link to nothing coming off that node. Basically something that looks like this:

The way I’m doing this is with a small invisible panel (portId terminal) off to the right, and I’m linking from the blank port (which is the main/central panel) to terminal, both on spot.RightCenter.

The issue I’m running into is that I can’t figure out how to get the panel to be ignored by the layout. I can set visible:false, but then the link doesn’t connect appropriately. If I leave the panel visible, then the links look correct, but the margins between nodes are changed in a way I don’t want. Is there a way to mix these two behaviors?

I’ve also tried using spots with offsets, but the behavior of the link itself seems really odd in that case, I’m not sure if that’s down to something I did, or the spots not being intended for use that way.

Edit: I should also note that if possible, I’d like to keep the behavior of having the node link to itself, as there’s some side effects of that that are handy for other features.

node template:

export const getTaskTemplate = (localize: Function) => {
  return GO(
    go.Node,
    'Spot',
    {
      locationObjectName: 'TASK_BODY',
      selectionObjectName: 'TASK_BODY',
      avoidableMargin: new go.Margin(30),
      deletable: false,
      selectionAdornmentTemplate: GO(
        go.Adornment,
        'Auto',
        GO(go.Shape, 'Rectangle', { fill: null, stroke: '#2470ad', strokeWidth: 4 }),
        GO(go.Placeholder)
      ),
    },
    GO(
      go.Panel,
      'Auto',
      {
        desiredSize: new go.Size(360, 120),
        isPanelMain: true,
        alignment: go.Spot.Center,
        click: selectTaskLinks,
        doubleClick: toggleDataObjectPanels,
        contextMenu: getTaskContextMenu(localize),
        toolTip: GO(
          'ToolTip',
          GO(
            go.Panel,
            'Vertical',
            GO(
              go.TextBlock,
              {
                margin: 3,
              },
              new go.Binding('text', '', (part) => {
                return part.data.inputs[0].name;
              }).ofObject()
            )
          )
        ),
      },
      GO(
        go.Shape,
        'SmallRoundedRectangle',
        {
          name: 'TASK_BODY',
          portId: '',
          strokeWidth: 3,
          fill: GO(go.Brush, { color: '#B5DEFF' }),
        },
        new go.Binding('stroke', 'state', getStatusIconColor)
      ),
    GO(go.Panel, 'Auto', {
      name: 'arrow_terminal',
      desiredSize: new go.Size(30, 2),
      alignment: go.Spot.RightCenter,
      alignmentFocus: go.Spot.LeftCenter,
      // visible: false,
      background: "red",
      portId: 'terminal',
      toSpot: go.Spot.RightCenter,
    })
  );
};

link template:

map.add(
      'continuationLinks',
      GO(
        go.Link,
        {
          fromSpot: go.Spot.RightCenter,
          toSpot: go.Spot.RightCenter,
          deletable: false,
        },
        GO(
          go.Shape,
          {
            name: 'OBJSHAPE',
          }
        ),
        GO(
          go.Shape,
          {
            name: 'ARWSHAPE',
            toArrow: 'Standard',
          }
        )
      )
    );

adding link to model:

continuations.map((value: string) => {
        links.push({
          id: value+"continuation",
          from: value,
          to: value,
          fromPort: '',
          toPort: 'terminal',
          category: 'continuationLinks',
        });
      });

And to clarify, when I tried to use the offset with no panel, the issue is that I cannot get the arrowhead orientation to cooperate. I tried setting segmentOrientation to none and changing the shape angle, setting the segmentOrientation to along path, opposite, etc, nothing will change the arrow orientation from backwards:
image

Override Layout.getLayoutBounds to return the position and size (in document coordinates) of the area of the node that you want it to consider as being the whole node.
Layout | GoJS API

That’s a property of the layout that’s called on the node as a whole, or individually per part?

so I’d do something like this?

Layout.getLayoutBounds = function(part, rect) {
  if(part.name === "arrow_terminal") {
    return new Rect(0,0);
  } else {
    return Layout.getLayoutBounds.prototype.call(part, rect)
  }
}

The main problem I see with this is it’d make group layouts hard. I guess I’d just subclass it?

If this is called with the entire node at once, that might get overly complex fairly quickly, since my nodes are quite complicated. I’m not sure how hard it would be to override a function that takes the entire node and just remove the one part.

No, Layout.getLayoutBounds is always passed a Part, normally a Node. It is not passed any GraphObject inside the node.

For the Diagram.layout, you could just set that instance’s boundsComputation property. For Layouts that are the Group.layout, yes, you’ll need to define a subclass and override the method, so that copies of the Group have layouts with the same behavior.

Oof. Is there a chance you can email me the source of that function so I can just modify that rather than trying to reimplement it?

Here’s how it’s defined in the Layout class:

  public getLayoutBounds(part: Part, rect?: Rect): Rect {
    const func = this.boundsComputation;
    if (func !== null) {
      if (!rect) rect = new Rect();
      return func(part, this, rect);
    }
    if (!rect) return part.actualBounds;
    rect.set(part.actualBounds);
    return rect;
  }

I think you’ll want to do something like:

  getLayoutBounds(part: Part, rect?: Rect): Rect {
    if (!rect) rect = new go.Rect();
    rect.set(part.actualBounds);
    const arrow = part.findObject("arrow_terminal");
    if (arrow !== null) rect.width -= arrow.actualBounds.width;
    return rect;
  }

Separate comment: you really shouldn’t have any Panels without any elements.

Should I replace it with something? I’m fine to do this with the spot offset and no panel, but I can’t get the arrow orientation to cooperate.

Can you just use a Shape?

Sure. I just need anything that I can set as a port to anchor the toSpot to.

Hmm, so I extended and replaced the layout, but I’m not seeing that function getting called. Am I missing something?

The arrow itself is working great though, after some tweaking.

Consumer:

export const getLayout = () => {
  return GO(LayeredFlowLayout, {
    isOngoing: true,
    isInitial: true,
    isRouting: false,
    setsPortSpot: false,
    treeStyle: go.TreeLayout.StyleLayered,
    layerStyle: go.TreeLayout.LayerUniform,
    alignment: go.TreeLayout.AlignmentCenterChildren,
    breadthLimit: 0,
    compaction: go.TreeLayout.CompactionBlock,
    arrangement: go.TreeLayout.ArrangementVertical,
    path: go.TreeLayout.PathDestination,
    arrangementSpacing: new go.Size(30, 30),
    layerSpacing: 80,
    nodeSpacing: 80,
  });
};

Layout:

import * as go from 'gojs';

export class LayeredFlowLayout extends go.TreeLayout {

  public constructor() {
    super();
  }

  public getLayoutBounds(part: go.Part, rect?: go.Rect): go.Rect {
    if (!rect) rect = new go.Rect();
    rect.set(part.actualBounds);
    const shape = part.findObject("arrow_terminus");
    if (shape !== null) rect.width -= shape.actualBounds.width;
    return rect;
  }
}

I just translated it to JavaScript and tried it. It seems to work for me:

function LayeredFlowLayout() {
  go.TreeLayout.call(this);
}
go.Diagram.inherit(LayeredFlowLayout, go.TreeLayout);

LayeredFlowLayout.prototype.getLayoutBounds = function(part, rect) {
  if (!rect) rect = new go.Rect();
  rect.set(part.actualBounds);
  const shape = part.findObject("arrow_terminus");
  if (shape !== null) rect.width -= shape.actualBounds.width;
  return rect;
}

Use:

function init() {
    var $ = go.GraphObject.make;

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

    myDiagram.nodeTemplate =
      $(go.Node, "Spot",
        $(go.Panel, "Auto",
          $(go.Shape,
            { fill: "white", portId: "", fromLinkable: true, toLinkable: true, cursor: "pointer" },
            new go.Binding("fill", "color")),
          $(go.TextBlock,
            { margin: 8, editable: true },
            new go.Binding("text").makeTwoWay())
        ),
        $(go.Shape, "Diamond",
          {
            name: "arrow_terminus",
            width: 50, height: 10,
            alignment: go.Spot.Right, alignmentFocus: go.Spot.Left
          })
      );

    myDiagram.model = new go.GraphLinksModel(
    [
      { key: 1, text: "Alpha", color: "lightblue" },
      { key: 2, text: "Beta", color: "orange" },
      { key: 3, text: "Gamma", color: "lightgreen" },
      { key: 4, text: "Delta", color: "pink" }
    ],
    [
      { from: 1, to: 2 },
      { from: 1, to: 3 },
      { from: 3, to: 4 },
    ]);
  }

Without the name “arrow_terminus”, Layout.getLayoutBounds doesn’t shrink the bounds any, so the wider nodes cause the columns/layers to be spread out more:
image

With that name, the layout thinks each node is 50 narrower than it actually is, producing basically the same results as if the Diamond Shape were not a piece of each Node:
image

Without that Diamond Shape:
image