Binding based on modelData and LinkData with undoManager

Hi,

I am using the modelData to store visualization properties for my links. Based on this visualization property and my linkdata, I am calculating a stroke color for example for the link. In my binding on the linkdata I lookup the modelData property and calculate my stroke color according to that property and my link data. This seems to work nicely:

  • If my linkData changes, my stroke color gets re-calculated
  • if my modelData changes I call diagram.updateAllTargetBindings and I skip the undoManager so that my target bindings get re-evaluated

However, when the undoManager is involved I see that the bindings do not get re-evaluated so my target properties (e.g. stroke) does not get evaluated based on the modelData that is currently in the model. Instead, it shows the old state. Example:

  • modelData of visualization set to LINK_DATA_COLOR
  • Update linkData color to red, green, blue → diagram stroke bindings reflect the changes properly
  • set modelData of visualization to BLACK → diagram stroke bindings get re-evaluated because of the updateAllTargetBindings and all diagram links strokes are visualized black
  • When I undo, I would expect the diagram links to stay black but instead it they toggle red, green, blue so the bindings are not re-evaluated properly.

Is there something wrong in how I am approaching this?

Thanks

You could have two Bindings for the same target property, one regular one (i.e. ofData) and one whose source is Model.modelData (i.e. ofModel).

That way any call to Model.set of either the link data object of the declared source property or the shared Model.modelData object of its declared source property will automatically evaluate all of the appropriate Bindings.

Thanks, will give that a try

Hi Walter,

I tried your approach by introducing two bindings on the same target property. This also seems to work an ofcourse is more efficient than updating all target bindings, so thanks for that.

However, it is still not solving by undo problem. The data gets properly updating on model changes but my bindings are not re-evaluated when the undomanager is involved. Am I missing something here? Would a toy example help?

Example

<!DOCTYPE html>
<html>

<head>
  <title>Data changes</title>
  <meta charset="UTF-8">
</head>

<body onload="init()">
  <div id="app">
    <div id="diagram" style="border: solid 1px black; width:100%; height:300px"></div>
    <div style="display: flex;">
      <button onclick="updateNodeColor()">Update node color</button>
      <button onclick="updateModelColorMode()">Update model color mode</button>
    </div>
    <pre id="modelData">
    </pre>
  </div>

  <script src="https://unpkg.com/gojs"></script>
  <script id="code">
    function init() {

      var $ = go.GraphObject.make;  // for conciseness in defining templates

      myDiagram = $(go.Diagram, "diagram",  // create a Diagram for the DIV HTML element
        {
          "undoManager.isEnabled": true  // enable undo & redo
        });

      // define a simple Node template
      myDiagram.nodeTemplate =
        $(go.Node, "Auto",  // the Shape will go around the TextBlock
          $(go.Shape, "RoundedRectangle", { strokeWidth: 0, fill: "white" },
          // Shape.fill is bound to Node.data.color
          new go.Binding("fill", "color", (value, target) => {
            if (target.diagram.model.modelData.colorMode === 'nodeColor') {
              return value
            } else {
              return 'black'
            }
          }),
          new go.Binding("fill", "colorMode", (value, target) => {
            if (value === 'nodeColor') {
              return target.part.data.color
            } else {
              return 'black'
            }
          }).ofModel()),
          $(go.TextBlock,
            { margin: 8, font: "bold 14px sans-serif", stroke: '#333' }, // Specify a margin to add some room around the text
            // TextBlock.text is bound to Node.data.key
            new go.Binding("text", "key"))
        );

      // but use the default Link template, by not setting Diagram.linkTemplate

      // create the model data that will be represented by Nodes and Links
      myDiagram.model = new go.GraphLinksModel(
        [
          { key: "Alpha", color: "red" },
        ]
      );
      myDiagram.model.modelData = {
        colorMode: 'nodeColor' // or colorBlack
      }
      myDiagram.model.linkKeyProperty = 'key'

      myDiagram.addModelChangedListener(() => {
        document.getElementById('modelData').innerHTML = myDiagram.model.toJson();
      })
    }

    function updateNodeColor() {
      const value = myDiagram.model.nodeDataArray[0].color === 'red' ? 'green' : 'red'

      myDiagram.model.startTransaction('updateNodeColor');
      myDiagram.model.setDataProperty(myDiagram.model.nodeDataArray[0], 'color', value);
      myDiagram.model.commitTransaction('updateNodeColor');
    }

    function updateModelColorMode() {
      const value = myDiagram.model.modelData.colorMode === 'nodeColor' ? 'FIXED_BLACK_COLOR' : 'nodeColor'

      myDiagram.model.skipsUndoManager = true;
      myDiagram.model.startTransaction('updateModelColorMode');
      myDiagram.model.set(myDiagram.model.modelData, 'colorMode', value);
      myDiagram.model.commitTransaction('updateModelColorMode');
      myDiagram.model.skipsUndoManager = false;
    }
  </script>
</body>

</html>

Did you mean to leave setting skipsUndoManager to true?

Yes, I do not want to track changes in the modelData but I do want to track changes in the nodeDataArray

OK, well, that means that the UndoManager won’t record changes to GraphObjects in the Diagram made during that time.

Am I missing what it is that you want?

out

So when I update the model using the buttons, my behavior is correct:

  • When the colorMode is set to FIXED_BLACK_COLOR, the color will always be black

When I update the model using the ctrl+z button, the model says FIXED_BLACK_COLOR but the node changes color. So apparently the bindings are not re-evaluated with the updated modelValue colorMode.

Bindings are never supposed to be evaluated when an undo or a redo happens.

Just as event handlers are not supposed to be called due to an undo or a redo.

Undo and redo are just supposed to restore state.

Makes sense, however, question still remains: How can achieve such behavior? So:

  • global modelData state that holds visualization properties of which I do not want to track history
  • Having a nodeData binding that uses this modelData state with history
  • Updating the diagram parts properly on undo / redo base on the updated modelData state

It seems to me that you have contradictory requirements.

Should undo restore the previous state of some shape’s fill color or not? I think that you get a consistent (but different) results whether you set skipsUndoManager or not.

I suppose you could play with calling Model.updateTargetBindings, but I’m not confident that you’ll get what you want.

For sure when I do not skip the undomanager, the model state is in sync with the diagram. The thing is that I do not want to track these changes in the modelData. So undo should restore the previous state of the shape’s fill color and it is doing that in the model data, which seems correct. But not in the diagram since the modelData variable is set to FIXED_BLACK_COLOR. I thought the diagram data and the model data should always be in sync but apparently they can go out of sync when you skip the undo manager. So is the diagram tracking history as well as the model?

Would it be possible to modify the diagram history if the modelData changes?

Maybe this examples illustrates the problem better:

<!DOCTYPE html>
<html>

<head>
  <title>Model and node data binding</title>
  <meta charset="UTF-8">
</head>

<body onload="init()">
  <div id="app">
    <div id="diagram" style="border: solid 1px black; width:100%; height:300px"></div>
    <div style="display: flex;">
      <button onclick="updateLabel()">Update label</button>
      <button onclick="updateLabelSuffix()">Update label suffix</button>
    </div>
    <pre id="modelData">
    </pre>
  </div>

  <script src="https://unpkg.com/gojs"></script>
  <script id="code">
    function init() {

      var $ = go.GraphObject.make;  // for conciseness in defining templates

      myDiagram = $(go.Diagram, "diagram",  // create a Diagram for the DIV HTML element
        {
          "undoManager.isEnabled": true  // enable undo & redo
        });

      // define a simple Node template
      myDiagram.nodeTemplate =
        $(go.Node, "Auto",  // the Shape will go around the TextBlock
          $(go.Shape, "RoundedRectangle", { strokeWidth: 0, fill: "white" }),
          $(go.TextBlock,
            { margin: 8, font: "bold 14px sans-serif", stroke: '#333' }, // Specify a margin to add some room around the text
            // TextBlock.text is bound to Node.data.key
            new go.Binding("text", "label", (value, target) => {
              return value + ' - ' + target.diagram.model.modelData.labelSuffix
            }),
            new go.Binding("text", "labelSuffix", (value, target) => {
              return target.part.data.key + ' - ' + value
            }).ofModel()),
        );

      // but use the default Link template, by not setting Diagram.linkTemplate

      // create the model data that will be represented by Nodes and Links
      myDiagram.model = new go.GraphLinksModel(
        [
          { key: "Alpha", label: 'Alpha' },
        ]
      );
      myDiagram.model.modelData = {
        labelSuffix: 'coolSuffix'
      }
      myDiagram.model.linkKeyProperty = 'key'

      myDiagram.addModelChangedListener(() => {
        document.getElementById('modelData').innerHTML = myDiagram.model.toJson();
      })
    }

    function updateLabel() {
      const value = myDiagram.model.nodeDataArray[0].label === 'Alpha' ? 'Bravo' : 'Alpha'

      myDiagram.model.startTransaction('updateLabel');
      myDiagram.model.setDataProperty(myDiagram.model.nodeDataArray[0], 'label', value);
      myDiagram.model.commitTransaction('updateLabel');
    }

    function updateLabelSuffix() {
      const value = myDiagram.model.modelData.labelSuffix === 'coolSuffix' ? 'awesomeSuffix' : 'coolSuffix'

      myDiagram.model.skipsUndoManager = true;
      myDiagram.model.startTransaction('updateLabelSuffix');
      myDiagram.model.set(myDiagram.model.modelData, 'labelSuffix', value);
      myDiagram.model.commitTransaction('updateLabelSuffix');
      myDiagram.model.skipsUndoManager = false;
    }
  </script>
</body>

</html>

So the model state and the diagram state go out of sync here when the undo manager is involved.

Can you use a property on the modelData object that is the binding source property and another property whose changes are not recorded?

Nope, that’s the thing here, I would like to compute the text (in the last example) based on both the nodeData and the modelData. If it would be in the modelData, this would not be a problem.

Sorry, I wasn’t clear. I was suggesting having two properties on the modelData, each having only whatever range of values that you were going to have for the “labelSuffix” property. One of those properties would be tracked by the UndoManager, and the other would not. You would need to figure out exactly what binding behavior you really wanted, if any, since I do not understand it.

I am still not getting what you mean exactly. The labelSuffix is stored in the modelData but the label is stored in the nodeDataArray. In this example we only have one node but for my real application I have multiple. If this does not clear things up, could you perhaps update the example to illustrate what you mean?

Sorry, I can’t, because I still don’t understand the behavior that you want.

Hi again,

I’ve worked on a new example in order to illustrate the desired behavior that we want:

<!DOCTYPE html>
<html>

<head>
  <title>Model and node data binding</title>
  <meta charset="UTF-8">
</head>

<body onload="init()">
  <div id="app">
    <div id="diagram" style="border: solid 1px black; width:100%; height:300px"></div>
    <div style="display: flex; border: 1px solid black; padding: 10px;">
      <div style="width: 50%">
        <h3 style="margin-top: 0px;">NodeData</h3>
        <table>
          <tr><td>Key</td><td>color1</td><td>color2</td><td>color3</td></tr>
          <tr>
            <td>Alpha</td>
            <td>
              <select id="alphaColor1" onchange="updateColor('alpha', 'color1', document.getElementById('alphaColor1').value)">
                <option>red</option>
                <option>green</option>
                <option>blue</option>
              </select>
            </td>
            <td>
              <select id="alphaColor2" onchange="updateColor('alpha', 'color2', document.getElementById('alphaColor2').value)">
                <option>red</option>
                <option>green</option>
                <option>blue</option>
              </select>
            </td>
            <td>
              <select id="alphaColor3" onchange="updateColor('alpha', 'color3', document.getElementById('alphaColor3').value)">
                <option>red</option>
                <option>green</option>
                <option>blue</option>
              </select>
            </td>
          </tr>
          <tr>
            <td>Bravo</td>
            <td>
              <select id="bravoColor1" onchange="updateColor('bravo', 'color1', document.getElementById('bravoColor1').value)">
                <option>red</option>
                <option>green</option>
                <option>blue</option>
              </select>
            </td>
            <td>
              <select id="bravoColor2" onchange="updateColor('bravo', 'color2', document.getElementById('bravoColor2').value)">
                <option>red</option>
                <option>green</option>
                <option>blue</option>
              </select>
            </td>
            <td>
              <select id="bravoColor3" onchange="updateColor('bravo', 'color3', document.getElementById('bravoColor3').value)">
                <option>red</option>
                <option>green</option>
                <option>blue</option>
              </select>
            </td>
          </tr>
          <tr>
            <td>Charlie</td>
            <td>
              <select id="charlieColor1" onchange="updateColor('charlie', 'color1', document.getElementById('charlieColor1').value)">
                <option>red</option>
                <option>green</option>
                <option>blue</option>
              </select>
            </td>
            <td>
              <select id="charlieColor2" onchange="updateColor('charlie', 'color2', document.getElementById('charlieColor2').value)">
                <option>red</option>
                <option>green</option>
                <option>blue</option>
              </select>
            </td>
            <td>
              <select id="charlieColor3" onchange="updateColor('charlie', 'color3', document.getElementById('charlieColor3').value)">
                <option>red</option>
                <option>green</option>
                <option>blue</option>
              </select>
            </td>
          </tr>
        </table>
      </div>
      <div style="width: 50%;">
        <h3 style="margin-top: 0px;">Visualization settings</h3>
        Which color property to use as node color:
        <select onchange="updateColorVisualizationSettings()" id="colorVisualizationSetting">
          <option>color1</option>
          <option>color2</option>
          <option>color3</option>
        </select>
      </div>
    </div>
    <pre style="border: 1px solid black; padding: 10px" id="modelData">
    </pre>
  </div>

  <script src="https://unpkg.com/gojs"></script>
  <script id="code">
    function init() {
      var $ = go.GraphObject.make;  // for conciseness in defining templates

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

      // define a simple Node template
      myDiagram.nodeTemplate = $(go.Node, "Auto",
        $(go.Shape, "RoundedRectangle", { strokeWidth: 0 },
          new go.Binding("fill", "", (value, target) => {
            return value[target.diagram.model.modelData.colorVisualizationSetting]
          }),
          new go.Binding("fill", "colorVisualizationSetting", (value, target) => {
            return target.part.data[value]
          }).ofModel(),
        ),
        $(go.TextBlock,
          { margin: 8, font: "bold 14px sans-serif", stroke: '#333' },
          new go.Binding("text", "key")
        )
      );

      myDiagram.model = new go.GraphLinksModel(
        [
          { key: "alpha", color1: 'red', color2: 'blue', color3: 'green' },
          { key: "bravo", color1: 'green', color2: 'red', color3: 'blue' },
          { key: "charlie", color1: 'blue', color2: 'green', color3: 'red' },
        ]
      );
      myDiagram.model.modelData = {
        colorVisualizationSetting: 'color1'
      }
      myDiagram.model.linkKeyProperty = 'key'

      myDiagram.addModelChangedListener((e) => {
        if (e.isTransactionFinished) {
          document.getElementById('modelData').innerHTML = myDiagram.model.toJson();

          document.getElementById('colorVisualizationSetting').value = myDiagram.model.modelData.colorVisualizationSetting

          document.getElementById('alphaColor1').value = myDiagram.model.findNodeDataForKey('alpha').color1
          document.getElementById('alphaColor2').value = myDiagram.model.findNodeDataForKey('alpha').color2
          document.getElementById('alphaColor3').value = myDiagram.model.findNodeDataForKey('alpha').color3

          document.getElementById('bravoColor1').value = myDiagram.model.findNodeDataForKey('bravo').color1
          document.getElementById('bravoColor2').value = myDiagram.model.findNodeDataForKey('bravo').color2
          document.getElementById('bravoColor3').value = myDiagram.model.findNodeDataForKey('bravo').color3

          document.getElementById('charlieColor1').value = myDiagram.model.findNodeDataForKey('charlie').color1
          document.getElementById('charlieColor2').value = myDiagram.model.findNodeDataForKey('charlie').color2
          document.getElementById('charlieColor3').value = myDiagram.model.findNodeDataForKey('charlie').color3
        }
      })
    }

    function updateColor(key, prop, value) {
      myDiagram.model.startTransaction('updateColor');
      myDiagram.model.setDataProperty(myDiagram.model.findNodeDataForKey(key), prop, value);
      myDiagram.model.commitTransaction('updateColor');
    }

    function updateColorVisualizationSettings() {
      const value = document.getElementById("colorVisualizationSetting").value;

      myDiagram.model.skipsUndoManager = true;
      myDiagram.model.startTransaction('colorVisualizationSettingUpdate');
      myDiagram.model.set(myDiagram.model.modelData, 'colorVisualizationSetting', value);
      myDiagram.model.commitTransaction('colorVisualizationSettingUpdate');
      myDiagram.model.skipsUndoManager = false;
    }
  </script>
</body>

</html>

On the left you can configure the node data array and on the right the visualization settings. We don’t want to track visualization settings in the undo manager since it is a user setting and it should be persistent.

Please execute the following sequence:

  1. Change the node data key color1 to red for alpha, bravo an charlie: the node colors will update since the visualization setting is set to color1 and we are changing color1
  2. Now change the visualization setting to color2: the node colors update to all colors that are set in the color2 column of the node data
  3. Focus on the diagram and undo your history: As can be seen, the model data gets reverted nicely without altering the visualization setting. However, the diagram gets out of sync with the model data.

The desired behavior would be that the node data gets reverted (as is happening) but that the diagram node colors stay the same (since we have set the visualization setting to color2).

Does this clear things up? We are looking for a solution or a direction to implement this behavior.