setCategoryForLinkData() does not cause link to be redrawn

Hi,

I am using four link templates. The selection of these templates is not a function of a single string property of the link data. Instead, I provide a function as the linkCategoryProperty of the diagram’s model. I should say that I could accomplish this by creating an “empty string” binding on the entire link data object, but because we encounter large diagrams I have avoided such bindings altogether in the interest of efficiency. And indeed, the change of link category is driven by a user-initiated action, so I know exactly the moment that the category should be changed. And I call setCategoryForLinkData at that moment.

Everything works as expected, except that the link is not redrawn on the spot. I have verified that

  • when setCategoryForLinkData is called, it in turn calls the function bound to linkCategoryProperty with a single argument (the data) and is returned the correct category
  • refreshing the browser tab or forcing a reload of the diagram without a refresh (as though loading a new diagram from scratch) both draw the link correctly (per the new template)
  • the link data is at it should be before and after
  • all of the relevant model mutations and the call to setCategoryForLinkData are happening within a single (not nested) transaction.

The two data properties that determine a link’s category are src and type. The former is the linkFromPortIdProperty for the model, and the latter is not associated with any bindings. The link category determines the link’s stroke and strokeWidth properties, among other things. The user actions causes the type property of the data model to change. The src data property is never mutated. Another wrinkle is that stroke also has a binding (Object binding) to isHighlighted. If it were not for that, it would be tempting to set stroke on creation of the link and use a simple binding on type to modify it over the lifetime of the link.

Any suggestions?

Here are a few snippets of the pertinent code:

makeEmptyDiagramModel()
{
    let $ = go.GraphObject.make;
    return $(go.GraphLinksModel,
             {
		 copyNodeDataFunction: obj => {

		     // deep copy of the node's model data
		     let data = this.copyModelData(obj);

		     // delete the key so that copy/paste operations will provide a new one
		     delete data.key;

		     return data;
		 },

		 nodeCategoryProperty:   "cat",
		 linkCategoryProperty:   (data, cat) => { return this.linkTemplateCategory(data); },
		 linkFromPortIdProperty: "src",
		 linkToPortIdProperty:   "dst",
		 linkKeyProperty:        "key"
             });
}

linkTemplateCategory(data)
{
    if (this.isDynamicPort(data.src))
    {
	return (data.type && data.type != "?") ? "typedEW" : "untypedEW";
    }
    else
    {
	return (data.type && data.type != "?") ? "typedNS" : "untypedNS";
    }
}

makeLinkTemplates(dict)
{
    let map = new go.Map();

    map.add("untypedEW", this.makeLinkTemplate("lightslategray", 2, 1));
    map.add("untypedNS", this.makeLinkTemplate("darkslategray", 1, 2));
    map.add("typedEW", this.makeLinkTemplate("blue", 2, 1));
    map.add("typedNS", this.makeLinkTemplate("midnightblue", 1, 2));

    return map;
}

bindEdgeTypeCmd(menuItem) { this.askEdgeType(type => { this.doEdgeType(menuItem, "bindEdgeType", type); }); }

askEdgeType(success)
{
    let validate = text => { return this.WS.test(text) ? "?" : this.validateJSONType(text); };
    let failure = () => { return "invalid type"; };
    let cancel  = () => {};
    this.prompt("edge type:", "", validate, success, failure, cancel);
}

doEdgeType(menuItem, f, type)
{
    let diagram = this.findActiveDiagram();
    let selection = diagram.selection;
    if (this.canEdgeTypeSelection(selection))
    {
	diagram.startTransaction("EdgeType");
	this[f](selection, type);
	diagram.commitTransaction("EdgeType");
	this.signalMenu(menuItem, true);
    }
    else
    {
	this.signalMenu(menuItem, false);
    }
}

canEdgeTypeSelection(selection)
{
    let k = 0;
    selection.each(part => {
	if (part instanceof go.Link)
	{
	    ++ k;
	}
    });
    return k > 0;
}

bindEdgeType(selection, type)
{
    let model = this.findActiveModel();
    selection.each(part => {
	if (part instanceof go.Link)
	{
	    let data = part.data;

	    // propagate this type to the ports at both ends of the edge
	    this.setLinkType(model, part, type);

	    // record edge types that are explicitly set by the user
	    model.setDataProperty(data, "type", type);

	    // determine the new category of the edge as a fn of data
	    let newcategory = this.linkTemplateCategory(data);

	    // ** update the link category (should force it to be redrawn)
	    model.setCategoryForLinkData(data, newcategory);
	}
    });
}

The line following the // ** is the one I was expecting would cause the link to be recreated and redrawn.

Your GraphLinksModel.linkCategoryProperty seems to be OK for getting the category of a link data object, but it doesn’t handle modifying the category of a link data object. You correctly have the function taking two arguments, but you ignore the second argument which would be the new value for the property.

Here’s an example that demonstrates that it works well:

<!DOCTYPE html>
<html>
<head>
  <title>Minimal GoJS Sample</title>
  <!-- Copyright 1998-2021 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:600px"></div>
  <button id="myTestButton">Test</button>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="https://unpkg.com/gojs"></script>
  <script id="code">
function init() {
  const $ = go.GraphObject.make;
  myDiagram =
    $(go.Diagram, "myDiagramDiv",
      {
        "undoManager.isEnabled": true,
        "ModelChanged": e => {
          if (e.isTransactionFinished) document.getElementById("mySavedModel").value = e.model.toJson();
        }
      });

  myDiagram.linkTemplateMap.add("A",
    $(go.Link,
      $(go.Shape, { stroke: "green", strokeWidth: 3 }),
      $(go.TextBlock, "A")
    ));

  myDiagram.linkTemplateMap.add("B",
    $(go.Link, { curve: go.Link.Bezier },
      $(go.Shape, { stroke: "red", strokeWidth: 3 }),
      $(go.TextBlock, "B")
    ));

  myDiagram.model = $(go.GraphLinksModel,
    {
      linkCategoryProperty: function(data, cat) {
        if (arguments.length > 1) data.cat = cat; else return data.cat;
      },
      nodeDataArray:
        [
          { key: "Alpha" },
          { key: "Beta" },
          { key: "Gamma" },
          { key: "Delta" }
        ],
      linkDataArray:
        [
          { from: "Alpha", to: "Beta", cat: "A" },
          { from: "Alpha", to: "Gamma", cat: "A" },
          { from: "Gamma", to: "Delta", cat: "B" },
          { from: "Delta", to: "Beta", cat: "B" }
        ]
    });

  myDiagram.findLinkForData(myDiagram.model.linkDataArray[3]).isSelected = true;

  document.getElementById("myTestButton").addEventListener("click",
    e => {
      const link = myDiagram.selection.first();
      if (link instanceof go.Link) {
        myDiagram.model.commit(m => {
          const oldcat = m.getCategoryForLinkData(link.data);
          m.setCategoryForLinkData(link.data, oldcat === "A" ? "B" : "A");
        });
      }
    }
  );
}
window.addEventListener('DOMContentLoaded', init);
  </script>
</body>
</html>

Click on the Test button when a link is selected.

Note that if you want to use JavaScript’s arguments, you have to use a function, not an arrow function. Although in this case, since the second argument should never be undefined when setting the category, I suppose you could just compare: cat !== undefined instead of using arguments.length > 1.

Thanks for your prompt reply.

The reason that the linkCategoryProperty function ignores its second argument (cat) is that there seems to be nothing for the function to do. The link data has already been mutated properly prior to the call to this function. I could conceivably do that mutation in the linkCategoryProperty function rather than prior to the call to setCategoryForLinkData, but how to pass the type argument to that call?

This is the line that mutates the data associated with the link:

model.setDataProperty(data, "type", type);

Is it that the redraw fails to occur because the setCategoryForLinkData call has no side-effects?

Yes. If you are implementing the link category property as a function rather than as a simple property (i.e. the property name), the model has no idea of how the information is being recorded, so it is imperative that the function also handle setting the value. It cannot deduce that the data.type property has any significance. Even I as a programmer cannot tell from reading your code exactly how the information is saved, because I do not know how isDynamicPort is implemented.

So your linkTemplateCategory function when called with two arguments is what has to modify the link data object so that when it is called with one argument it returns the new category value.

OK. But can I “cheat” in the sense that the category I pass to setCategoryForLinkData (and thereby to the linkCategoryProperty function) is in fact not a category per se, i.e., not a key in the link template map? If I could pass the type or some other value of my choosing, then I’d be all set. I am happy to return a proper key in the link template map of course.

That sounds like the wrong thing to do, so the behavior is not supported, but it might work. What happens when you try it?

It does indeed redraw the link. But it appears to use the default link template, with a black stroke and a different link routing algorithm than my templates specify. The behavior is consistent with what you’d expect if GoJS took the type I’ve passed in for a link category.

It looks like setCategoryForLinkData assumes that the link data has a string property for the category. That is, it is hard for me to see how it is possible to use it unless that is the case. But then linkCategoryProperty must name this property. So I think my use case is sort of out-of-bounds here.

You just have to make sure your function called with two arguments modifies the link data state so that when your function is called with one argument returns the result you want.

In the following example the template names are “AA” and “BB”, but the data property only has a single character. The data could have been distributed over multiple properties, if that’s what you need.

<!DOCTYPE html>
<html>
<head>
  <title>Minimal GoJS Sample</title>
  <!-- Copyright 1998-2021 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:600px"></div>
  <button id="myTestButton">Test</button>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="https://unpkg.com/gojs"></script>
  <script id="code">
function init() {
  const $ = go.GraphObject.make;
  myDiagram =
    $(go.Diagram, "myDiagramDiv",
      {
        "undoManager.isEnabled": true,
        "ModelChanged": e => {
          if (e.isTransactionFinished) document.getElementById("mySavedModel").textContent = e.model.toJson();
        }
      });

  myDiagram.linkTemplateMap.add("AA",
    $(go.Link,
      $(go.Shape, { stroke: "blue", strokeWidth: 3 }),
      $(go.TextBlock, "AA")
    ));

  myDiagram.linkTemplateMap.add("BB",
    $(go.Link, { curve: go.Link.Bezier },
      $(go.Shape, { stroke: "red", strokeWidth: 3 }),
      $(go.TextBlock, "BB")
    ));

  myDiagram.model = $(go.GraphLinksModel,
    {
      linkCategoryProperty: function(data, cat) {
        if (arguments.length > 1) {
          data.cat1 = cat[0];
         } else {
           return data.cat1 + data.cat1;
         }
      },
      nodeDataArray:
        [
          { key: "Alpha" },
          { key: "Beta" },
          { key: "Gamma" },
          { key: "Delta" }
        ],
      linkDataArray:
        [
          { from: "Alpha", to: "Beta", cat1: "A" },
          { from: "Alpha", to: "Gamma", cat1: "A" },
          { from: "Gamma", to: "Delta", cat1: "B" },
          { from: "Delta", to: "Beta", cat1: "B" }
        ]
    });

  myDiagram.findLinkForData(myDiagram.model.linkDataArray[3]).isSelected = true;

  document.getElementById("myTestButton").addEventListener("click", e => {
      const link = myDiagram.selection.first();
      if (link instanceof go.Link) {
        myDiagram.model.commit(m => {
          const oldcat = m.getCategoryForLinkData(link.data);
          m.setCategoryForLinkData(link.data, oldcat === "AA" ? "BB" : "AA");
        });
      }
    });
}
window.addEventListener('DOMContentLoaded', init);
  </script>
</body>
</html>

I finally understood what is going on. I think the condition is slightly stronger than what you write. The function must return the desired result when called with one argument (that was already the case with my code), but critically it must return a different value after the two-argument call, than it does before the two-argument call. In other words, it must cause the one-argument call to change value. I suspect that the GoJS code records the original category (by calling the one-argument version), then calls the two-argument version, and afterwards compares the second argument to the recorded original category, and recreates the link if they differ.

I fixed my code by using data.temp to pass some state into the two-argument call, perform all the necessary data modifications in the two-argument function, and then delete data.temp. works fine now.

Thanks,

-Luddy

Yes, that’s right – if getCategoryForLinkData doesn’t return a new value, why bother changing templates?

That’s right, and of course, anyone would naturally want to make the data changes that will cause a new template to be required within setCategoryForLinkedData, and I wanted to also. The difficulty is that that function’s signature doesn’t provide for anything other than the old link data and the new category to be parameters to the change. In your example, you used the first character of the new category to fiddle the data state, but if something other than the old data and the new category string is required, then it’s less obvious how to do it. Anyhow: attaching temporary properties to the data, and deleting them after the call to setCategoryForLinkedData did the trick.