Understanding Link.points and making custom link routes persistent

Hello,

I am trying to understand Link.points as I want to make custom link routes persistent which differ from what the set routing would calculate (e.g. if adjusted/resegmented by the user). In the screenshot below, I count 6 points making up the links route, including start and end. The serialization of Link.points though has 8 entries, which is what confuses me. Does this have something to do with start and end segment lengths or something similar? Is there any documentation on how a list of Link.points can be interpreted?

Due to our underlying application’s requirements I need to save the route of the link by only specifying the points where the link has corners and changes direction, as marked in the second screenshot below.

Do you have any suggestion on how to implement this? Initially I thought I might just be able to set Link.points only using the red-marked corners, as the links start and end points are anyway defined by the nodes and ports. But seemingly also start and end points are contained in Link.pionts.

Thanks in advance!

Yes, Link.points has to be the complete list of Points used in the link’s route. But I can see how the rest of your app wants to only work with the intermediate points.

I would leave the computation of Link.points (by the Link.computePoints method) alone because it’s pretty complicated in order to handle all of the kinds of links and customizations that there might be. In your diagrams I suppose it can be a lot simpler, because you only deal with orthogonal routes, but even just that case can be pretty complicated.

There are six points (i.e. five segments) by default in an orthogonal route, which handles most cases. However if you add points, or if the user does when Link.resegmentable is true, then you generally need to add two points each time in order to maintain orthogonality. I think that’s why in your case there are eight points, not six.

I can look into this and do some experiments to see if there’s a good solution.

Hi Walter, thanks for the quick reply and for looking into it!

In our application, the initial serialization of a diagram is created outside of GoJS and afterwards loaded for visualization and editing. So the list of indermediate points is what we have in the beginning.

In the ideal case, I am looking for a possiblity for a two-way binding to Link.points where a custom conversion function handles a correct insertion of such intermediate points into Link.points. Another possibility would also be to have an initial conversion mechanism of our list of intermediate points to Link.points on diagram load, and afterwards just have a two-way binding without any specific conversions.

If you are using the Model.toJson method to perform your serialization, you will see that for the “points” property it has written out an Array of numbers, holding the alternating x and y values for each Point in the List. When you send that information back to the rest of your app model, you can easily ignore the first and last points (i.e. the first two numbers and the last two numbers).

What’s more complicated is computing the correct end points given the intermediate points. That’s something that I can experiment on.

Unfortunately I have to stick to a specific serialization format and do not use Model.toJson.

So that means that it is not possible to just replace the intermediate points, except the first and last two, with the points that I initially get as the route’s desired edge points (not coming from GoJS)? There is more calculation needed?

Yes, presumably the route should extend to the desired points at the ports, so that the path Shape looks like it extends that far. Otherwise there would be a gap near each node.

Here’s a demonstration of not saving the end points (first and last points) of the link route in the model when using a TwoWay Binding on the Link.points property.

<!DOCTYPE html>
<html>
<head>
  <title>Not saving endpoints in "points" Array</title>
  <!-- Copyright 1998-2025 by Northwoods Software Corporation. -->
</head>
<body>
  <div style="width: 100%; display: flex; justify-content: space-between">
    <div style="display: flex; flex-direction: column; margin: 0 2px 0 0">
      <div id="myPaletteDiv" style="flex-grow: 1; width: 140px; background-color: floralwhite; border: solid 1px black"></div>
      <div id="myOverviewDiv" style="margin: 2px 0 0 0; width: 140px; height: 100px; background-color: whitesmoke; border: solid 1px black"></div>
    </div>
    <div id="myDiagramDiv" style="flex-grow: 1; height: 400px; border: solid 1px black"></div>
  </div>
  <div>
    <button id="myLoadButton">Load</button>
    <button id="mySaveButton">Save</button>
  </div>
  <textarea id="mySavedModel" style="width:100%;height:200px">
{ "class": "GraphLinksModel",
  "pointsDigits": 1,
  "nodeDataArray": [
{"key":1,"text":"hello","color":"green","location":"-20 40"},
{"key":2,"text":"world","color":"red","location":"160 80"}
],
  "linkDataArray": [{"from":1,"to":2,"points":[10.2,40.0,42.2,40.0,42.2,-17.3,110.6,-17.3,110.6,80.0,128.0,80.0]}]}
  </textarea>

  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go-debug.js"></script>
  <script id="code">

// initialize main Diagram
const myDiagram =
  new go.Diagram("myDiagramDiv", {
      "draggingTool.isGridSnapEnabled": true,
      "commandHandler.archetypeGroupData": { isGroup: true, text: "Group", color: "green" },
      "undoManager.isEnabled": true
    });

myDiagram.nodeTemplate =
  new go.Node("Auto", { locationSpot: go.Spot.Center })
    .bindTwoWay("location", "location", go.Point.parse, go.Point.stringify)
    .add(
      new go.Shape({
          fill: "white", stroke: "gray", strokeWidth: 2,
          portId: "", fromLinkable: true, toLinkable: true,
          fromLinkableDuplicates: true, toLinkableDuplicates: true,
          fromLinkableSelfNode: true, toLinkableSelfNode: true
        })
        .bind("stroke", "color"),
      new go.TextBlock({
          margin: new go.Margin(5, 5, 3, 5), font: "10pt sans-serif",
          minSize: new go.Size(16, 16), maxSize: new go.Size(120, NaN),
          editable: true
        })
        .bindTwoWay("text")
    );

myDiagram.linkTemplate =
  new go.Link({
      routing: go.Routing.Orthogonal,
      reshapable: true, resegmentable: true
    })
    .bindTwoWay("points", "points", loadPoints, savePoints)
    .add(new go.Shape({ strokeWidth: 2 }));

function loadPoints(list, link) {
  if (list.length >= 1) {
    const fromport = link.fromPort;
    const toport = link.toPort;
    if (fromport !== null && toport !== null) {
      const ortho = link.isOrthogonal;
      const fromnode = link.fromNode;
      fromnode.ensureBounds();
      const fromspot = link.computeSpot(true, fromport);  // link's Spot takes precedence, if defined
      const tonode = link.toNode;
      tonode.ensureBounds();
      const tospot = link.computeSpot(false, toport);  // link's Spot takes precedence, if defined
      const copy = list.copy();
      const start = link.getLinkPoint(fromnode, fromport, fromspot, true, ortho, tonode, toport);  // must be newly allocated result
      const end = link.getLinkPoint(tonode, toport, tospot, false, ortho, fromnode, fromport);  // must be newly allocated result
      copy.insertAt(0, start);
      copy.add(end);
      return copy;
    }
  }
  return list;
}

function savePoints(list, data, model) {
  const copy = list.copy();
  if (copy.length >= 2) {
    copy.pop();
    copy.removeAt(0);
  }
  return copy;
}

// initialize Palette
myPalette =
  new go.Palette("myPaletteDiv", {
      nodeTemplateMap: myDiagram.nodeTemplateMap,
      model: new go.GraphLinksModel([
        { text: "red node", color: "red" },
        { text: "green node", color: "green" },
        { text: "blue node", color: "blue" },
        { text: "orange node", color: "orange" }
      ])
    });

// initialize Overview
myOverview =
  new go.Overview("myOverviewDiv", {
      observed: myDiagram,
      contentAlignment: go.Spot.Center
    });

// save a model to and load a model from Json text, displayed below the Diagram
function save() {
  const str = myDiagram.model.toJson();
  document.getElementById("mySavedModel").value = str;
}
document.getElementById("mySaveButton").addEventListener("click", save);

function load() {
  const str = document.getElementById("mySavedModel").value;
  myDiagram.model = go.Model.fromJson(str);
}
document.getElementById("myLoadButton").addEventListener("click", load);

load();
  </script>
</body>
</html>

Note that this does not only save points at which the route turns. It just discards the end points. On load it computes the expected end points.

Hi Walter, thanks for looking into it. As soon as I have the time again, I will check how this could work out respecting the requirements of our application. Currently there are other urgent things for me to take care of. I will let you know!