Calculating P0 and P3 for Bezier

Hi GoJS team,

I’m looking for an advice because you’ve probably encountered similar problems (couldn’t find my exact problem on this forum though).

I use curve: Link.Bezier, bind points and do not specify any ports on nodes. I save points to a database and retrieve it so that user can create even more custom shape of their link on a diagram.

My diagram is using TreeLayout and GraphLinksModel. Nodes are being added to a diagram, some of them can be moved but only some of the nodes would have position saved.

My problem is that points are absolutely positioned and certain operations on a diagram may render them not pointing to an original node. Adding, removing, moving nodes, basically anything which in result would move the node around. It’s a multi-user environment so that change may be done by others and we may “download” it. Points have first (P0) and last point (P3) on the from and to node which may have moved.

I’m not sure if I’m using the wrong thing here (points) and there’s a better way or should I just react on many possible events on a diagram and… allow go.js to calculate points again? But will it, given user changed them previously? I’m afraid that there will be a version of a diagram (without some of the nodes because filtering or user-access needs to be taken into account) that these points will be irrelevant as well.

So I’m probably asking if there’s a way in go.js to store P1 and P2 control points of a Bezier curve and leave P0 and P3 to be determined by go.js because it knows from and to node and their position on a diagram after layout. Or should I calculate P0, P3 myself at some point after the layout of a diagram?Is that what people do in such scenarios? Is there a method inside the library which would determine crossing point between node bounds and link taking fromShortLength, toShortLength, toEndSegmentLength, fromEndSegmentLength. I would gladly use something from the inner of a library to avoid any possible discrepancy between my calculations and go.js’

Not sure if the above makes sense but I’m looking for something like curviness but with two guiding points which would work regardless of the node position.

Thanks for any advice!

Can users manually reshape any of the link routes? I would think that that was the only reason to bind the Link.points property. And even that might not be necessary, depending on what they can do.

Even if you had been only saving, or were only restoring, the middle two (control) points, they wouldn’t be valid if the relative locations of the connected nodes had changed.

So I’d hope that you’d use the standard routing parameters (such as Link.curviness and Link.[from|to]EndSegmentLength) to determine the route for each link. If that isn’t sufficient, because users can change some state and your code cannot automatically cause the desired routes to be recomputed, please describe the situation.

Thanks for replying @walter.

We’ve started with routing Orthogonal, then switched to curviness but in order to provide even more flexibility in defining the routes from node A to B, we’ve offered curve: Link.Bezier. That’s great because there’s a lot of flexibility and we avoided complaints that some routing is not “logical” or is not “clear”. Users can define their path themselves however they see fit.

So ideally I’d like to have two points of freedom and preserve some of the shape when nodes move for whatever reason. That’s why I thought - P1 and P2 would be absolute but P0 and P3 would be “recalculated” when nodes moved.

If that’s not possible or hard, I may “simply” reset points somehow when the diagram changes (although don’t know how to do that easily yet).

Maybe I’ll try to restate the question.

We would like to use two points of link adjustment for the convenience of the users and at the same time we would like to preserve the shape of the links as much as possible when nodes move on a diagram.

Going back to only curviness is just a fallback option if we can’t achieve the above.

Ah, I’ve often wished for a mode where instead of saving and restoring the whole route of a link (i.e. the Link.points) it would save only the “middle” points and restore those and automatically recompute the ultimate (and perhaps penultimate, depending on conditions) points of the route based on the (perhaps slightly modified size of the) connected nodes.

I thought I had implemented that many years ago. I’ll see if I can dig that up. It will probably not be a general solution, but only appropriate for a particular kind of link. I don’t remember if it included Bezier-curve routes.

I’d appreciate if you can find something like that.

That being said, I may try to implement it on my side as well.
The general question would be - when in the lifetime of a diagram should I be recalculating these points given that the position of nodes may be changed externally (like an admin removing some nodes visibility because of permissions). Would it be initial layout completed already? And then perhaps on certain diagram events as well (those which will move nodes on a diagram around)?

Thanks!

Yes, the problem is that some code might be modifying the model, thereby invalidating the saved coordinates in connected Link routes. Until the Nodes and Links have been rebuilt in a Diagram, there’s no way to tell what the right routes should be. So, yes, you are correct – in an “InitialLayoutCompleted” DiagramEvent listener would be an appropriate time to do the routing again. All of the Nodes and Links will have been reconstituted, including possibly different templates from when the model had been saved, and possibly wrong Link.points routes if those routes had been saved and reloaded.

That’s actually what I was looking for, but haven’t found yet. It shouldn’t be too hard to reimplement, though…

Here’s a complete stand-alone sample demonstrating saving and loading only the middle points of link routes – the Bezier control points.

<!DOCTYPE html>
<html>

<head>
  <title>Model keeping middle (control) points of Bezier Links</title>
  <!-- Copyright 1998-2024 by Northwoods Software Corporation. -->
  <meta name="description" content="Load link route as only middle control points of Bezier curve">
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>

<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:600px"></div>
  This does not use a Binding("points") on the Link template,
  but saves a <code>data.controls</code> Array of the middle points of the Link route.
  <div>
    <button id="myLoadButton">Load</button>
    <button id="mySaveButton">Save</button>
  </div>
  <textarea id="mySavedModel" style="width:100%;height:250px">
{ "class": "GraphLinksModel",
  "nodeDataArray": [
{"key":1,"text":"Alpha","location":"0 0"},
{"key":2,"text":"Beta","location":"100 30"},
{"key":3,"text":"Gamma","location":"0 100"},
{"key":4,"text":"Delta","location":"100 130"}
],
  "linkDataArray": [
{"from":1,"to":2,"controls":[110,-10,10,-40]},
{"from":3,"to":4,"controls":[110,90,10,140]}
]}
  </textarea>

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

const myDiagram =
  new go.Diagram("myDiagramDiv",
    {
      "undoManager.isEnabled": true,
      // Instead of having a TwoWay Binding on the Link.points property,
      // when the Link.points property changes, save the middle points to the link data object,
      // and when loading a model update the link routes based on those saved control points.
      "InitialLayoutCompleted": e => {
        loadAllControls(e.diagram);
        // note that this listener must be removed before loading a model
        e.diagram.addChangedListener(onChangedSaveControls);
      }
    });

function loadAllControls(diagram) {
  diagram.links.each(link => {
    const arr = link.data.controls;
    if (!Array.isArray(arr) || arr.length < 4) return;
    const from = link.fromPort;
    const to = link.toPort;
    if (from === null || to === null) return;
    const c1 = new go.Point(arr[0], arr[1]);
    const c2 = new go.Point(arr[2], arr[3]);
    const p1 = link.getLinkPointFromPoint(from.part, from, from.getDocumentPoint(go.Spot.Center), c1, true);
    const p2 = link.getLinkPointFromPoint(to.part, to, to.getDocumentPoint(go.Spot.Center), c2, false);
    const list = new go.List();
    list.add(p1);
    list.add(c1);
    list.add(c2);
    list.add(p2);
    link.points = list;
  })
}

function onChangedSaveControls(e) {
  if (e.change === go.ChangedEvent.Property &&
      e.object instanceof go.Link &&
      e.propertyName === "points") {
    const link = e.object;
    if (link.curve !== go.Link.Bezier) return;
    const pts = link.points;
    if (pts.count < 4) return;
    const data = link.data;
    if (!data) return;
    const model = link.diagram.model;
    const arr = [];
    for (let i = 1; i < pts.count-1; i++) {
      const p = pts.elt(i);
      arr.push(Math.round(p.x * 100)/100);
      arr.push(Math.round(p.y * 100)/100);
    }
    model.set(data, "controls", arr);
  }
}

myDiagram.nodeTemplate =
  $(go.Node, "Auto",
    { locationSpot: go.Spot.Center },
    new go.Binding("location", "location", go.Point.parse).makeTwoWay(go.Point.stringify),
    $(go.Shape, "Circle", { fill: "lightgray" }),
    $(go.TextBlock, new go.Binding("text"))
  );

myDiagram.linkTemplate =
  $(go.Link,
    { curve: go.Curve.Bezier, adjusting: go.LinkAdjusting.Stretch, reshapable: true },
    $(go.Shape),
    $(go.Shape, { toArrow: "Standard" })
  );


// 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() {
  // It's important to temporarily remove the onChangedSaveControls listener
  // so that it doesn't save the default routing produced when loading.
  myDiagram.removeChangedListener(onChangedSaveControls);

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

// diagram initialization
load();
myDiagram.selectCollection(myDiagram.links);
  </script>
</body>
</html>

Thanks a lot, will try to incorporate this into our set up and see how will it work. Appreciate the time to find this!

FYI, I’ve cleaned up the code a bit since I first posted.

Thanks @walter, with some modifications for my case and Typescript, the initial recalculation worked so thank you very much.

Now I have to think about what type of diagram change events should trigger this recalculations because I can’t think of “one” event which would basically indicate that any node has changed its location.

Thank you very much for help!

What do node location changes have to do with anything? If you try the sample I posted above, the control points are automatically saved to the link data object(s).

It may work well in your example but perhaps there’s more happening on my diagram. Basically when I add new nodes using the TreeLayout (which merges nodeDataArray), these points do not update themselves. The only time they are changed is when user manipulates them directly using linkingTool. Otherwise I can reload the diagram and they will be recalculated just fine. So that’s why I said I need to figure out when to recalculate apart from initialLayoutCompleted.

Yes, disable the automatic updating of the control points (remove the listener) before merging the data, and then call loadAllControls and add the listener in a “LayoutCompleted” DiagramEvent listener.