Multiple groups with same content (reference)

Hi,

I would like to create a group “template” and use multiple instances of this template while keeping the relation to that “template”. Is this possible using GOJS? I created an illustration of what I mean:

ezgif-2-314dc578f0aa

Here I simply copied the group so here there are two groups with their own nodes and links. However, I would like to refer to a template when creating / copying a group instead of creating new nodes / links. So that I modify a node in group Alpha, it automatically updates the properties in group Alpha2.

This way, a user can create “templates” and re-use these templates in the drawing. When the templates get’s updated, it will updated for all the instances of the “template”.

Is this possible?

Thanks.

You are correct that the visual tree of a Diagram is truly tree-structured. There are no cases where the same GraphObject, whether Node or Link or Shape or TextBlock, is present more than once in the visual tree. (Brushes and Geometries can be shared freely, but they are immutable once they have been used by a GraphObject.)

Templates are automatically copied when new Parts (Nodes, Groups, Links) are added to the model. There are no JavaScript pointers (references) from the copies to the templates. But each Part has a Part.category property that is the name of the template that the Part was copied from. So you can look up the template by name in the Diagram.nodeTemplateMap (or the groupTemplateMap or linkTemplateMap). But that really doesn’t help you because modifying a template has no effect on any of the copies, so no Part in the Diagram will be modified and appear/behave differently

You could use the same ideas to implement your own templates and then when a shared property changes you can find and modify all instances. If you like I can create an example for you later today.

Thanks for the explanation.

If you like I can create an example for you later today.

That would be really helpful!

Hmmm, there are so many possibilities here. Do you expect the number of templates to be fixed at the time that the programmer finishes work? Or do you expect the end user to define new ones dynamically?

Also, your animated GIF seems to imply that Beta and Beta2 are of the same “template”. Was that the intent? If so, was the shared property supposed to be the Y value of the node location?

What kinds of properties would you want to get shared values?

If you haven’t already read https://gojs.net/latest/intro/dataBinding.html, please do so.

Here’s an example of using a “template” property on the node data which names a particular shared object that holds the properties used in the one node template used by the app. That shared object is stored in the shared Model.modelData object.

After you click either of the HTML buttons below the diagram, you can see the updated model in the <textarea> at the bottom. Undo and redo work, of course.

<!DOCTYPE html>
<html>
<head>
<title>Minimal GoJS Sample</title>
<!-- Copyright 1998-2020 by Northwoods Software Corporation. -->
<meta charset="UTF-8">
<script src="go.js"></script>
<script id="code">
  function TEMPLATE(target, prop) {
    return new go.Binding(target, "template",
                          function(t, obj) { return (obj.diagram.model.modelData[t])[prop]; });
  }

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

    myDiagram =
      $(go.Diagram, "myDiagramDiv",
          {
            "undoManager.isEnabled": true,
            "ModelChanged": function(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",
        $(go.Shape,
          { width: 50, height: 50, fill: "white" },
          { portId: "", fromLinkable: true, toLinkable: true, cursor: "pointer" },
          TEMPLATE("figure", "figure"),
          TEMPLATE("fill", "color")),
        $(go.TextBlock,
          { margin: new go.Margin(4, 0, 0, 0), editable: true },
          new go.Binding("text").makeTwoWay(),
          TEMPLATE("stroke", "color"))
      );

    myDiagram.model =
      $(go.GraphLinksModel,
        {
          modelData: {
            een: { color: "blue", figure: "TriangleUp" },
            twee: { color: "orange", figure: "Square" },
            drie: { color: "green", figure: "Circle" }
          },
          nodeDataArray:
          [
            { key: 1, text: "Alpha", template: "een" },
            { key: 2, text: "Beta", template: "een" },
            { key: 3, text: "Gamma", template: "een" },
            { key: 4, text: "Delta", template: "twee" },
            { key: 5, text: "Epsilon", template: "twee" },
            { key: 6, text: "Zeta", template: "twee" },
            { key: 7, text: "Eta", template: "drie" },
            { key: 8, text: "Theta", template: "drie" },
            { key: 9, text: "Iota", template: "drie" }
          ]
        });

    // select some nodes, so the second BUTTON will operate on some nodes
    myDiagram.findNodeForKey(3).isSelected = true;
    myDiagram.findNodeForKey(6).isSelected = true;
    myDiagram.findNodeForKey(9).isSelected = true;
  }

  function test() {  // modify template
    myDiagram.model.commit(function(m) {
      var t = m.modelData.een;
      var c2 = (t.color === "blue") ? "red" : "blue";
      var f2 = (t.figure === "TriangleUp") ? "TriangleDown" : "TriangleUp";
      m.set(m.modelData, "een", { color: c2, figure: f2 });
      myDiagram.updateAllTargetBindings("template");
    });
  }

  function test2() {  // change template of selected nodes
    myDiagram.commit(function(diag) {
      diag.selection.each(function(n) {
        if (n instanceof go.Node) {
          var t = n.data.template;
          var t2 = (t === "een") ? "twee" : ((t === "twee") ? "drie" : "een");
          diag.model.set(n.data, "template", t2);
        }
      });
    });
  }
</script>
</head>
<body onload="init()">
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:600px"></div>
  <button onclick="test()">Modify template "een"</button>
  <button onclick="test2()">Change template of selected nodes</button>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>
</body>
</html>

To make it easier to define your special template bindings, I’ve defined a TEMPLATE helper function. Its first argument is the GraphObject target property name. Its second argument is the source data property name – but the source is not the node data, as would be normal, but the object named by the “template” property in the Model.modelData shared object. I hope this indirection is clear to you – ask if you don’t understand what’s going on.

Thanks for your reply. I am aware of the databinding and the templates. Maybe I should rephrase what I am trying to do here.

I would like the templates to be dynamic (a user should be able to modify a template).

Yes.

I tried your example and cooked up an example of my own to illustrate what I am trying to do:

<!DOCTYPE html>
<html>

<head>
  <title>Reference groups</title>
  <meta charset="UTF-8">
</head>

<body onload="init()">
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:600px"></div>

  <script src="go.js"></script>
  <script id="code">
    function init() {
      var descriptions = {
        root_description: {
          waypoints: []
        },
        description1: {
          waypoints: [
            { name: "waypoint1", location: new go.Point(300, 50) },
            { name: "waypoint2", location: new go.Point(300, 300) }
          ]
        }
      }
      var instances = [
        {
          id: "root",
          parentId: null,
          description: "root_description", // reference to description map above
          location: new go.Point(0, 0)
        },
        {
          id: "child1",
          parentId: "root",
          description: "description1",
          location: new go.Point(0, 100)
        },
        {
          id: "child2",
          parentId: "root",
          description: "description1",
          location: new go.Point(500, 100)
        }
      ]

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

      myDiagram =
        $(go.Diagram, "myDiagramDiv",
          {
            "grid.visible": true,
            "ModelChanged": function (e) {
              if (e.isTransactionFinished) {  // show the model data in the page's TextArea
                console.log(e)
              }
            }
          });

      myDiagram.nodeTemplate = $(go.Node, "Auto",
        $(go.Shape, "Ellipse", { fill: "white" }),
        $(go.TextBlock, new go.Binding("text", "key")),
        new go.Binding("location", "data", function (d) { 
          return d.instance.location.add(d.waypoint.location)
        })
      )

      myDiagram.groupTemplate = $(go.Group, "Vertical",
        $(go.Panel, "Auto",
          $(go.Shape, "RoundedRectangle",  // surrounds the Placeholder
            {
              parameter1: 14,
              fill: "rgba(128,128,128,0.33)"
            }),
          $(go.Placeholder,    // represents the area of all member parts,
            { padding: 5 })  // with some extra padding around them
        ),
        $(go.TextBlock,
          { alignment: go.Spot.Right, font: "Bold 12pt Sans-Serif" },
          new go.Binding("text", "instance", function (i) { return `${i.id} (${i.description})` })
        ),
        new go.Binding("location", "instance", function (i) { return i.location })
      )

      // Construct diagram

      function getInstanceNodes(instance) {
        let nodes = [
          { key: instance.id, isGroup: true, instance: instance, group: instance.parentId ? instance.parentId : undefined }
        ]
        let description = descriptions[instance.description]
        description.waypoints.forEach((w) => {
          nodes.push({ key: `${instance.id}-${w.name}`, group: instance.id, data: {waypoint: w, instance: instance }})
        })

        return nodes
      }

      var nodeDataArray = []
      instances.forEach((instance) => {
        console.log(instance)
        nodeDataArray = nodeDataArray.concat(getInstanceNodes(instance))
      })

      console.log(nodeDataArray)

      myDiagram.model = new go.GraphLinksModel(nodeDataArray, []);
    }
  </script>
</body>

</html>

So basically what I am trying to do is creating a SceneGraph with Instances that have pointers to Descriptions. Every instance holds the following properties: [id, description (reference), location]. A user should be able to modify Descriptions and all Instances pointing to that description should be updated accordingly.

Could you give some insight whether this is possible using GoJS? In my example, on top, I declare my model of Instances and Descriptions. When constructing the GoJS diagram, I am basically flattening this model but keep the instances in a group (so that all the description waypoints for example move when I move the instance). This is desired since all waypoints are defined w.r.t. the instance location. However, while I was implementing this, I find that I have to do a lot of book keeping since everything is expressed in the global coordinates. So I am not sure whether this is the way to go. What do you think?

On the other hand, we have the instances that should be pointing to the same descriptions. I was thinking of implementing this by listening to the model changed events, look up the description and update all instances holding that description accordingly. Again, I am doubting whether this is the way to go. What do you think?

Thanks again for your help!

Yes, all Parts use document coordinates, so Groups and their members all use the same coordinate system, not a coordinate system that is specific for each Group.

Ignoring the problem with pretending to have group coordinates, is there a problem with either what I outlined or what you outlined? I guess I’m confused about what you want help with.

Regarding relative locations for member Nodes (relative to their containing Groups), I suppose you could look at this old sample:

<!DOCTYPE html>
<html>
<head>
<title>Basic GoJS Sample</title>
<!-- Copyright 1998-2020 by Northwoods Software Corporation. -->
<meta charset="UTF-8">
<script src="go.js"></script>
<script id="code">
  function init() {
    var $ = go.GraphObject.make;  // for conciseness in defining templates

    myDiagram =
      $(go.Diagram, "myDiagramDiv",  // create a Diagram for the DIV HTML element
        {
          // allow double-click in background to create a new node
          "clickCreatingTool.archetypeNodeData": { text: "Node", color: "white" },

          // allow Ctrl-G to call groupSelection()
          "commandHandler.archetypeGroupData": { text: "Group", isGroup: true, color: "blue" },

          "ModelChanged": function(e) {
            if (e.isTransactionFinished) {
              document.getElementById("savedModel").textContent = myDiagram.model.toJson();
            }
          },

          // enable undo & redo
          "undoManager.isEnabled": true
        });

    // Define the appearance and behavior for Nodes:

    // Assume the data.loc property has a relative location for the node compared to its containing group.
    // (If there is no group data, it is the location in document coordinates.)
    // toLocation should not depend on any Node.location because they might not have been initialized yet.
    function toLocation(data) {
      var loc = go.Point.parse(data.loc);
      if (data.group !== undefined) {
        var groupdata = myDiagram.model.findNodeDataForKey(data.group);
        if (groupdata) {
          loc.add(toLocation(groupdata));
        }
      }
      return loc;
    };

    // fromLocation just saves in data.loc either the absolute location if there's no containing Group,
    // or the relative location with its containing Group.
    function fromLocation(location, data) {
      if (data.group !== undefined) {
        var group = myDiagram.findNodeForKey(data.group);
        if (group) {
          var loc = location.copy().subtract(group.location);
          data.loc = loc.x.toFixed(2) + " " + loc.y.toFixed(2);  //go.Point.stringify(loc);
        }
      } else {
        data.loc = go.Point.stringify(location);
      }
    };

    // These nodes have text surrounded by a rounded rectangle
    // whose fill color is bound to the node data.
    // The user can drag a node by dragging its TextBlock label.
    // Dragging from the Shape will start drawing a new link.
    myDiagram.nodeTemplate =
      $(go.Node, "Auto",
        new go.Binding("location", "", toLocation).makeTwoWay(fromLocation),
        $(go.Shape, "RoundedRectangle",
          {
            fill: "white", // the default fill, if there is no data-binding
            portId: "", cursor: "pointer",  // the Shape is the port, not the whole Node
            // allow all kinds of links from and to this port
            fromLinkable: true, fromLinkableSelfNode: true, fromLinkableDuplicates: true,
            toLinkable: true, toLinkableSelfNode: true, toLinkableDuplicates: true
          },
          new go.Binding("fill", "color")),
        $(go.TextBlock,
          {
            font: "bold 14px sans-serif",
            stroke: '#333',
            margin: 6,  // make some extra space for the shape around the text
            isMultiline: false,  // don't allow newlines in text
            editable: true  // allow in-place editing by user
          },
          new go.Binding("text", "text").makeTwoWay())  // the label shows the node data's text
      );

    // The link shape and arrowhead have their stroke brush data bound to the "color" property
    myDiagram.linkTemplate =
      $(go.Link,
        { relinkableFrom: true, relinkableTo: true },  // allow the user to relink existing links
        $(go.Shape,
          { strokeWidth: 2 },
          new go.Binding("stroke", "color")),
        $(go.Shape,
          { toArrow: "Standard", stroke: null },
          new go.Binding("fill", "color"))
      );

    // Define the appearance and behavior for Groups:

    // Groups consist of a title in the color given by the group node data
    // above a translucent gray rectangle surrounding the member parts
    myDiagram.groupTemplate =
      $(go.Group, "Vertical",
        new go.Binding("location", "", toLocation).makeTwoWay(fromLocation),
        { selectionObjectName: "PANEL",  // selection handle goes around shape, not label
          ungroupable: true },  // enable Ctrl-Shift-G to ungroup a selected Group
        $(go.TextBlock,
          {
            font: "bold 19px sans-serif",
            isMultiline: false,  // don't allow newlines in text
            editable: true  // allow in-place editing by user
          },
          new go.Binding("text", "text").makeTwoWay(),
          new go.Binding("stroke", "color")),
        $(go.Panel, "Auto",
          { name: "PANEL" },
          $(go.Shape, "Rectangle",  // the rectangular shape around the members
            { fill: "rgba(128,128,128,0.2)", stroke: "gray", strokeWidth: 3 }),
          $(go.Placeholder, { padding: 10 })  // represents where the members are
        )
      );

    // Define the behavior for the Diagram background:

    // Create the Diagram's Model:
    var nodeDataArray = [
      { key: 1, text: "Alpha", color: "lightblue", loc: "0 0" },
      { key: 2, text: "Beta", color: "orange", loc: "100 0" },
      { key: 3, text: "Gamma", color: "lightgreen", group: 5, loc: "10 10" },
      { key: 4, text: "Delta", color: "pink", group: 5, loc: "100 100" },
      { key: 5, text: "Epsilon", color: "green", isGroup: true, loc: "50 100" }
    ];
    var linkDataArray = [
      { from: 1, to: 2, color: "blue" },
      { from: 2, to: 2 },
      { from: 3, to: 4, color: "green" },
      { from: 3, to: 1, color: "purple" }
    ];
    myDiagram.model = new go.GraphLinksModel(nodeDataArray, linkDataArray);
  }
</script>
</head>
<body onload="init()">
<div id="sample">
  <div id="myDiagramDiv" style="border: solid 1px black; width:400px; height:400px"></div>
  <pre id="savedModel" />
</div>
</body>
</html>

Thanks Walter. Using this way, I can indeed cover the SceneGraph relative positioning.

This leaves me with one last question regarding Instances pointing to Descriptions. These Instances are going to be modeled as a GoJS Group. This GoJS Group is going to contain Waypoints which are modeled as normal GoJS Nodes. The template example you provided is sharing Node properties (shape / color) but I am looking to share the nodes that are within that group. So basically the question is whether a group template can hold a sequence of nodes. I know a group can hold nodes but my goal is to put these in the template in some way (if possible).

If this is not possible, I have to handle updating related instances with the same descriptions when a change is detected in one of the descriptions’s nodes. Would like to hear whether this is the way to go then.

Hope this question is clear now :). Really appreciate the help! Thanks.

Template Parts cannot have relationships with other Parts. So if somehow it were possible for a Group template to have several member Nodes in the template, then would you expect that instantiating the Group would also make copies of the Nodes?

So for what you want to do, where there is shared state associated with an instance of a template that is shared by the Group’s member Nodes, you’ll need to implement that yourself.

But for an example of where a Group “template-like thing” really does have member Nodes, you can do it if everything gets copied, as happens in the Macros sample: https://gojs.net/latest/samples/macros.html. In that Palette there is a template-like thing consisting of a Group and three member Nodes and three member Links. When the user drag-and-drops a “Macro” Group from the Palette to the main Diagram, all seven parts get copied. Since the group is collapsed, you only see one node, but in that sample the “ExternalObjectsDropped” DiagramEvent listener automatically ungroups the newly copied group, revealing the copied member nodes and links.

Again, I should repeat that there’s no shared state in that sample. But of course you could have such state in the data properties. And as you realize, if any shared state is mutated you may need to find all of the referring Parts and update them, probably by calling updateTargetBindings. In the sample I gave you above, I took the easy way out and just calling Diagram.updateAllTargetBindings, but it would be more efficient to find the particular Parts and call updateTargetBindings just on those Nodes whose node.data.template value was the name of the template that was modified.

Thanks for the explanation Walter. For me it is clear how to move on from here.

Thanks again for all the help!