How to pin nodes to the edge of the viewport/canvas

I wanted help with understanding how to pin nodes at the edges of the canvas of the gojs.
The pinned nodes should always stay at edges even after zooming in and out while. all nodes in between them are free to move.

In the image below, the canvas is represented in blue color while the pinned nodes are green

Are there unpinned nodes that are scrolled and zoomed normally?

Should those pinned nodes change apparent size as the diagram is zoomed in or out?

What should happen if the Div (HTMLDivElement) changes size?

Yes

They shouldn’t but later if i want them to, how big a change would it be.

If by Div changing size u mean the canvas width changing, we can assume that the canvas has width and height set to 100%.

All nodes accept the pinned should always stay at the same position i.e. at the edge of the screen as seen in the screenshot above.

I think you want to do something along the lines described at GoJS Legends and Titles -- Northwoods Software

However, there are still several choices you can make.

I still need the nodes to be able to connect to the non pinned nodes.

Also, how do i position them at the location as seen in the screenshot shared. for example: if i give specific coordinates as location, on different screen resolutions the coordinates would be different. How can i ensure irrespective of screen resolution changes, the nodes are always fixed at the location in the screenshot

You want there to be links connecting a pinned node with an unpinned (regular) node? So as the user scrolls or zooms such links are automatically re-routed, perhaps causing lots of links crossing each other?

How are the unpinned nodes arranged – is there a Diagram.layout arranging them or are they all positioned manually by the user?

The unpinned nodes are manually positioned.

OK, here you go:

<!DOCTYPE html>
<html>
<head>
  <title>Left Right Layout</title>
  <!-- Copyright 1998-2023 by Northwoods Software Corporation. -->
  <script src="go.js"></script>
  <script id="code">
class LeftRightLayout extends go.Layout {
  constructor() {
    super();
    this.isViewportSized = true;
  }

  doLayout(coll) {
    var diagram = this.diagram;
    if (diagram === null) return;
    coll = this.collectParts(coll);
    var left = null;
    var right = null;
    coll.each(function(part) {
      if (part instanceof go.Node) {
        // customize this to identify the parts (probnably Groups) that should go at the top and
        // at the bottom of the viewport
        if (part.key === "Left") left = part;
        else if (part.key === "Right") right = part;
      }
    });
    if (left) coll.remove(left);
    if (right) coll.remove(right);

    // implementations of doLayout that do not make use of a LayoutNetwork
    // need to perform their own transactions
    diagram.startTransaction("LeftRightLayout");

    var vb = new go.Rect(0, 0, diagram.viewportBounds.width, diagram.viewportBounds.height);
    var spacew = vb.width;
    var spaceh = vb.height;

    // move left and right parts to their respective positions in the viewport
    if (left) {
      if (left instanceof go.Group) {
        var bnds = diagram.computePartsBounds(left.memberParts);
        diagram.moveParts(left.memberParts, new go.Point(vb.x + left.placeholder.margin.left, vb.centerY - bnds.centerY))
      } else {
        var bnds = left.actualBounds;
        left.moveTo(vb.x, vb.centerY - bnds.height/2);
      }
      spacew -= left.actualBounds.width + 1;
      vb.x += left.actualBounds.width + 1;
      vb.width = Math.max(0, vb.width - left.actualBounds.width + 1);
    }
    if (right) {
      if (right instanceof go.Group) {
        var bnds = diagram.computePartsBounds(right.memberParts);
        diagram.moveParts(right.memberParts, new go.Point(vb.right - bnds.centerX - right.actualBounds.width/2, vb.centerY - bnds.centerY))
      } else {
        var bnds = right.actualBounds;
        right.moveTo(vb.right - right.actualBounds.width, vb.centerY - bnds.height/2);
      }
      spacew -= right.actualBounds.width + 1;
      vb.width = Math.max(0, vb.width - right.actualBounds.width + 1);
    }
    // now VB has the remaining available area

    // // arrange the rest of the parts that are in the middle
    // var glay = new go.GridLayout();
    // glay.doLayout(coll);
    // var bnds = diagram.computePartsBounds(coll);
    // // move rest of parts to center of viewport
    // diagram.moveParts(coll, new go.Point(vb.centerX - bnds.centerX, vb.centerY - bnds.centerY));

    diagram.commitTransaction("TopBottomLayout");
  }
}  // end of TopBottomLayout


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

  myDiagram =
    new go.Diagram("myDiagramDiv",
      {
        layout: $(LeftRightLayout),
        padding: 0,
        allowHorizontalScroll: false,
        //allowVerticalScroll: false,
        //allowZoom: false,
        "animationManager.isInitial": false,
        "undoManager.isEnabled": true,
        "ModelChanged": e => {     // just for demonstration purposes,
          if (e.isTransactionFinished) {  // show the model data in the page's TextArea
            document.getElementById("mySavedModel").textContent = e.model.toJson();
          }
        }
      });

  myDiagram.nodeTemplate =
    $(go.Node, "Vertical",
      { locationObjectName: "ICON", locationSpot: go.Spot.Center },
      new go.Binding("location", "loc", go.Point.parse).makeTwoWay(go.Point.stringify),
      $(go.Shape, "Circle",
        { name: "ICON", width: 40, height: 40, fill: "dodgerblue", strokeWidth: 0, portId: "" }),
      $(go.TextBlock,
        new go.Binding("text"))
    );

  myDiagram.nodeTemplateMap.add("Left",
    $(go.Node, "Vertical",
      { copyable: false, deletable: false, movable: false },
      $(go.Shape, "Circle",
        { width: 40, height: 40, fill: "lightgreen", strokeWidth: 0, portId: "" }),
      $(go.TextBlock,
        new go.Binding("text"))
    ));

  myDiagram.nodeTemplateMap.add("Right",
    $(go.Node, "Vertical",
      { copyable: false, deletable: false, movable: false },
      $(go.Shape, "Circle",
        { width: 40, height: 40, fill: "lightgreen", strokeWidth: 0, portId: "" }),
      $(go.TextBlock,
        new go.Binding("text"))
    ));

  myDiagram.groupTemplate =
    $(go.Group, "Auto",
      {
        selectable: false,
        isInDocumentBounds: false,
        layout: $(go.GridLayout, { wrappingColumn: 1 })
      },
      $(go.Shape, { fill: "#EEEEEE", strokeWidth: 0 }),
      $(go.Placeholder, { padding: new go.Margin(2000, 10) })
    );

  myDiagram.model = $(go.GraphLinksModel,
    {
      nodeCategoryProperty: "group",
      nodeDataArray: [
        { key: "Left", isGroup: true },
        { key: 1, group: "Left", text: "A" },
        { key: 2, group: "Left", text: "B" },
        { key: "Right", isGroup: true },
        { key: 11, group: "Right", text: "X" },
        { key: 12, group: "Right", text: "Y" },
        { key: 13, group: "Right", text: "Z" },
        { key: 100, text: "Alpha", loc: "250 200" },
        { key: 101, text: "Beta", loc: "200 300" },
        { key: 102, text: "Gamma", loc: "400 200" },
        { key: 103, text: "Delta", loc: "350 350" },
      ],
      linkDataArray: [
        { from: 1, to: 100 },
        { from: 2, to: 101 },
        { from: 100, to: 101 },
        { from: 100, to: 103 },
        { from: 101, to: 102 },
        { from: 102, to: 11 },
        { from: 102, to: 12 },
        { from: 103, to: 13 },
      ]
    });
}
  </script>
</head>
<body onload="init()">
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:600px; background-color: #DDDDDD"></div>
  The model saved in JSON format after each transaction:
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>
</body>
</html>

This produces:

Thanks will try this code and update here accordingly.

Hey Walter, was going through this solution snippet.

My layout is set with the following configuration:

layout: $(go.LayeredDigraphLayout, {
layerSpacing: 200,
isOngoing: false,
isInitial: false,
}),

So how would I implement the above solution is such a case. Is grouping necessary?

Can this be achieved by a data property say: fixedPosition = “left-center” | “right-top-center” | “right-bottom-center” ?

Any layout is going to position nodes the way that it thinks is best. That might not fit in the available space unless the diagram’s scale is decreased (zoomed out). For example, if the diagram Div is just 300 wide, there’s no way for a layout with layerSpacing == 200 to position everything nicely with the first and last layers at the left and right sides of the viewport.

But I see that you have set Layout.isInitial and isOngoing to false. So when do you expect the layout to be performed?

Using Groups is not required, but because I don’t know what you really want to do in all possible cases, I cannot say whether it is advantageous to use Groups or not. When you use that layout now, what happens?

What do you mean by “left-center” or “right-top-center”?

But I see that you have set Layout.isInitial and isOngoing to false. So when do you expect the layout to be performed?

The nodes are created dynamically and at the time of addNodeData I determine the location of the node. If it has a key fixedPosition, it will be added at that position, else it takes the documentPoint.

left-center and right-top-center are just values of the custom key “fixedPosition” that determines a fixed position for the nodes on the diagram and they should always remain in that position/location when the diagram is zoomed in or out. Any node without fixedPosition can be placed anywhere.

sharing a snippet of the code to update Node Location. In the below, the left-center node stays at it’s assigned position event on zooming in and out.
The problem is with the nodes on the right.

const updateNodeLocation = (node) => {
      const diagram = node.diagram;
      const documentPoint = diagram.lastInput.documentPoint;
      var vb = new go.Rect(
        diagram.viewportBounds.x,
        diagram.viewportBounds.y,
        diagram.viewportBounds.width,
        diagram.viewportBounds.height
      );
      const bnds = node.actualBounds;
      switch (node.data.fixedPosition) {
        case "left-center":
          node.moveTo(vb.x, vb.centerY - bnds.height / 2);
          node.movable = false;
          break;
        case "right-top-center":
          node.moveTo(
            vb.right - node.actualBounds.width,
            vb.centerY - vb.centerY / 2 - bnds.height / 2
          );
          node.movable = false;
          break;
        case "right-bottom-center":
          node.moveTo(
            vb.right - node.actualBounds.width,
            vb.centerY + vb.centerY / 2 - bnds.height / 2
          );
          node.movable = false;
          break;
        default:
          node.moveTo(documentPoint.x, documentPoint.y);
      }
    };

initidiagram:

      let d = $(go.Diagram, {
        "undoManager.isEnabled": true,
        "linkingTool.direction": go.LinkingTool.ForwardsOnly,
        "linkingTool.isEnabled": false,
        layout: $(go.LayeredDigraphLayout, {
          layerSpacing: 200,
          isOngoing: false,
          isInitial: false,
        }),
        allowHorizontalScroll: false,
        minScale: 0.3,
        maxScale: 2,
      });
      d.model = new go.GraphLinksModel({
        linkKeyProperty: "key",
      });
      d.initialPosition = new go.Point(0, 0);
      let origscale = NaN;
      d.addDiagramListener(
        "InitialLayoutCompleted",
        (e) => (origscale = e.diagram.scale)
      );
        .addDiagramListener("ViewportBoundsChanged", (e) => {
          // console.log("viewportBoundsChanged", e);
          if (isNaN(origscale)) return;
          const diagram = e.diagram;
          var newscale = diagram.scale;
          diagram.skipsUndoManager = true;
          diagram.startTransaction("Scale Nodes");
          diagram.nodes.each((n) => {
            if (n.data.fixedPosition) {
              n.scale = origscale / newscale;
              updateNodeLocation(n);
            }
          });
          diagram.commitTransaction("scale Nodes");
          diagram.skipsUndoManager = false;
        })

createNode

(data = {}) => {
        const diagram = canvasRef.current.getDiagram();
        let newNode = null;
        if (data?.key) {
          newNode = diagram.findNodeForKey(data.key);
        }
        if (newNode) {
          diagram.startTransaction("udpateNodeData");
          Object.keys(data).forEach((k) => {
            diagram.model.setDataProperty(newNode.data, k, data[k]);
          });
          diagram.commitTransaction("udpateNodeData");
        } else {
          newNode = {
            key: data.key || generateKey(),
            ...data,
          };
          diagram.startTransaction("createNode");
          diagram.model.addNodeData(newNode);
          diagram.commitTransaction("createNode");

          newNode = diagram.findNodeForKey(newNode.key);
          updateNodeLocation(newNode);
        }
        return newNode;
      }

Could you please explain what the problem is with the nodes on the right?

And explain what should happen when there are more layers than will fit into the viewport?
Or when the user zooms in or out?

I want that the nodes marked with left-center, right-bottom-center and right-top-center should never move from their positions even after zooming in and out. it should be fixed to that position, much like what position fixed does in css.

Also, I had to make a slight change in the updateNodeLocation method as it was causing issues when creating nodes that didn’t have fixedPositions.

var vb = new go.Rect(
0,
0,
diagram.viewportBounds.width,
diagram.viewportBounds.height
);

Try this:

Yes, THANK YOU. This is exactly what I was looking for.

hey Walter, after implementing the above, the nodes are now correctly placed and since I needed the fixed nodes to be selectable, I also removed the layername as well.
Also, I didn’t want the the fixed nodes to scale, so I added the code for stop scaling you had shared earlier.
I also added a selectionAdornment (Circle) for my Fixed template.

Now that the fixed node don’t scale, my expectation was that the custom selection adornment wouldn’t scale as well, but it did. How do I prevent that from happening.

Here is an updated snippet of the Fixed ViewSpot Locations example:

function updateFixedNodes() {
  // do it in a transaction that skips the UndoManager
  myDiagram.commit(diag => {
  	const newscale = diag.scale;
    diag.nodes.each(n => {
      // ignore regular nodes
      if (n.category !== "Fixed") return;
      // assume that Node.locationSpot == Spot.parse(spotstr)
      // now compute the point in document coords of that spot in the viewport
      const loc = new go.Point().setRectSpot(diag.viewportBounds, n.locationSpot);
      // and set the node's location, whose locationSpot is already set appropriately
      n.location = loc;
      console.log(origscale, newscale)
      n.scale = origscale/newscale
    });
  }, null);  // skipsUndoManager
}

myDiagram.nodeTemplateMap.add("Fixed",
  $(go.Node, "Auto",
  {selectionAdornmentTemplate: $(
      go.Adornment,
      "Spot",
      {
        name: "SELECTIONADORNMENTTEMPLATEGO",
        zOrder: 99, // zOrder should be less than zOrder of AddNodeAdornment
      },
      $(go.Placeholder),
      $(go.Shape, "Circle", {
        strokeWidth: 5,
        stroke: "black",
        width: 85,
        height: 65,
        fill: "transparent",
      })
    )},
    // { layerName: "Grid" },  // not selectable, not isInDocumentBounds
    new go.Binding("locationSpot", "viewSpot", go.Spot.parse),
    $(go.Shape,
      { fill: "white" },
      new go.Binding("fill", "color")),
    $(go.TextBlock,
      { margin: 8 },
      new go.Binding("text"))
  ));

Try binding the Adornment.scale too.

not sure how I would do that. can you please share a sample code or example?

Also, I have another adornedObject which is shown on mouseEnter of the fixed node and is removed on mouseLeave.

The scale should apply to both the adornments.

Hey Walter, any suggestions on the above.

I understand i need to do something like new go.Binding(“scale”, “”, ()=>{}) on the selectionadornment template or is it something else entirely.

I cannot figure what the function would look like or maybe the second parameter of the Binding.