Custom animation for subgraph expand/collapse

I have multiple nodes within a group node and sub graph expander button. on group expansion it has a default animation where the nodes start expanding from the top left corner of the group.

chrome-capture-2023-6-25

Is there a way we can use a custom animation here so that the nodes start expanding from top center and the size/scale of nodes should stay the same ?

Unfortunately there is no easy way to customize this. Though you are the second person to ask recently, so maybe its worth investigating how we can make this easier for users to change. Unfortunately I cannot promise any API changes soon for that.

wouldn’t it be possible to achieve it using the Animation or AnimationTrigger API ?

Yes, it’s possible, but it’s a bit of work. I had to override the CommandHandler.collapseSubGraph and expandSubGraph methods in order to re-implement them. Here’s the complete code in a copy of the Regrouping sample.

<!DOCTYPE html>
<html><body>
  <div id="sample">
    <div style="width: 100%; display: flex; justify-content: space-between">
      <div id="myPaletteDiv" style="width: 130px; margin-right: 2px; background-color: rgb(39, 29, 29); border: solid 1px black"></div>
      <div id="myDiagramDiv" style="flex-grow: 1; height: 500px; background-color: rgb(39, 29, 29); border: solid 1px black"></div>
    </div>
    <div id="buttons">
      <button id="saveModel" onclick="save()">Save</button>
      <button id="loadModel" onclick="load()">Load</button>
      Diagram Model saved in JSON format:
    </div>
    <textarea id="mySavedModel" style="width:100%;height:300px">
  { "class": "go.GraphLinksModel",
    "nodeDataArray": [
  {"key":1, "isGroup":true, "text":"Main 1", "horiz":true},
  {"key":2, "isGroup":true, "text":"Main 2", "horiz":true},
  {"key":3, "isGroup":true, "text":"Group A", "group":1},
  {"key":4, "isGroup":true, "text":"Group B", "group":1},
  {"key":5, "isGroup":true, "text":"Group C", "group":2},
  {"key":6, "isGroup":true, "text":"Group D", "group":2},
  {"key":7, "isGroup":true, "text":"Group E", "group":6},
  {"text":"first A", "group":3, "key":-7},
  {"text":"second A", "group":3, "key":-8},
  {"text":"first B", "group":4, "key":-9},
  {"text":"second B", "group":4, "key":-10},
  {"text":"third B", "group":4, "key":-11},
  {"text":"first C", "group":5, "key":-12},
  {"text":"second C", "group":5, "key":-13},
  {"text":"first D", "group":6, "key":-14},
  {"text":"first E", "group":7, "key":-15}
   ],
    "linkDataArray": [  ]}
    </textarea>
  </div>

  <script src="https://unpkg.com/gojs@2.3.7"></script>
  <script id="code">
const myDiagram = new go.Diagram("myDiagramDiv",
  {
    // when a drag-drop occurs in the Diagram's background, make it a top-level node
    mouseDrop: e => finishDrop(e, null),
    layout:  // Diagram has simple horizontal layout
      new go.GridLayout(
        { wrappingWidth: Infinity, alignment: go.GridLayout.Position, cellSize: new go.Size(1, 1) }),
    "commandHandler.archetypeGroupData": { isGroup: true, text: "Group", horiz: false },
    "undoManager.isEnabled": true
  });

// override the two CommandHandler commands for collapsing and expanding Groups
myDiagram.commandHandler.collapseSubGraph = function(group) {
  if (group === undefined) group = null;
  const diagram = this.diagram;
  const reason = 'Collapse SubGraph';
  const groups = new go.List();
  const map = new go.Map();
  try {
    diagram.startTransaction(reason);
    if (group instanceof go.Group && group.isSubGraphExpanded) {
      _collapseGroup(group, group, map);
      group.collapseSubGraph();
      groups.add(group);
    } else if (group === null) {
      const it = diagram.selection.iterator;
      while (it.next()) {
        const part = it.value;
        if (part instanceof go.Group && part.isSubGraphExpanded) {
          _collapseGroup(part, part, map);
          part.collapseSubGraph();
          groups.add(part);
        }
      }
      // ?? also change selection
    }
    diagram.raiseDiagramEvent('SubGraphCollapsed', groups);
  } finally {
    diagram.commitTransaction(reason);
    // compute end points for each collapsed group, but only after everything has been collapsed
    const mappts = new go.Map();
    groups.each(grp => mappts.add(grp, new go.Point(grp.actualBounds.centerX, grp.actualBounds.y)));
    // now determine Animations for each disappearing member part
    const an = new go.Animation();
    map.each(kvp => {
      const part = kvp.key;
      const info = kvp.value;
      const root = info.group;
      const temp = part.copy();
      temp.isLayoutPositioned = false;
      temp.layerName = "Grid";  // so that the temporary parts are behind the groups, so they appear to slide under
      an.addTemporaryPart(temp, diagram);
      const grppt = mappts.get(root);
      an.add(temp, 'position', part.position, new go.Point(Math.max(root.actualBounds.x, grppt.x - part.actualBounds.width/2), grppt.y));
    });
    an.start();
  }
}
function _collapseGroup(group, root, map) {  // gets called before the collapse
  group.findSubGraphParts().each(part => {
    if (!(part instanceof go.Link) && part.isVisible()) {
      const info = new go.DraggingInfo(part.position.copy());
      info.group = root;
      map.add(part, info);
    }
  });
}
myDiagram.commandHandler.expandSubGraph = function(group) {
    if (group === undefined) group = null;
    const diagram = this.diagram;
    const reason = 'Expand SubGraph';
    const groups = new go.List();
    const map = new go.Map();
    // compute start points for each collapsed group, but only before each one has been expanded
    const mappts = new go.Map();
    try {
      diagram.startTransaction(reason);
      if (group instanceof go.Group && !group.isSubGraphExpanded) {
        mappts.add(group, new go.Point(group.actualBounds.centerX, group.actualBounds.y));
        _expandGroup(group, group, map);
        group.expandSubGraph();
        groups.add(group);
      } else if (group === null) {
        const it = diagram.selection.iterator;
        while (it.next()) {
          const part = it.value;
          if (part instanceof go.Group && !part.isSubGraphExpanded) {
            mappts.add(part, new go.Point(part.actualBounds.centerX, part.actualBounds.y));
            _expandGroup(part, part, map);
            part.expandSubGraph();
            groups.add(part);
          }
        }
        // ?? also change selection
      }
      diagram.raiseDiagramEvent('SubGraphExpanded', groups);
    } finally {
      diagram.commitTransaction(reason);
      // now determine Animations for each disappearing member part
      const an = new go.Animation();
      map.each(kvp => {
        const part = kvp.key;
        const info = kvp.value;
        const root = info.group;
        const temp = part.copy();
        temp.isLayoutPositioned = false;
        temp.layerName = "Grid";  // so that the temporary parts are behind the groups, so they appear to slide under
        an.addTemporaryPart(temp, diagram);
        const grppt = mappts.get(root);
        an.add(temp, 'position', new go.Point(Math.max(root.actualBounds.x, grppt.x - part.actualBounds.width/2), grppt.y), part.position);
        part.opacity = 0.0;  // hide real members
      });
      an.finished = () => {
        map.each(kvp => {
          const part = kvp.key;
          part.opacity = 1.0;  // show real members ??? assuming opacity == 1.0
        });
      };
      an.start();
    }
  }
function _expandGroup(group, root, map) {  // gets called before the expand
  group.memberParts.each(part => {
    if (!(part instanceof go.Link) && !part.isVisible()) {
      const info = new go.DraggingInfo(part.position.copy());
      info.group = root;
      map.add(part, info);
      if (part instanceof go.Group && part.wasSubGraphExpanded && !part.isSubGraphExpanded) {
        _expandGroup(part, root, map);
      }
    }
  });
}

// The one template for Groups can be configured to be either layout out its members
// horizontally or vertically, each with a different default color.

function makeLayout(horiz) {  // a Binding conversion function
  if (horiz) {
    return new go.GridLayout(
      {
        wrappingWidth: Infinity, alignment: go.GridLayout.Position,
        cellSize: new go.Size(1, 1), spacing: new go.Size(4, 4)
      });
  } else {
    return new go.GridLayout(
      {
        wrappingColumn: 1, alignment: go.GridLayout.Position,
        cellSize: new go.Size(1, 1), spacing: new go.Size(4, 4)
      });
  }
}

function defaultColor(horiz) {  // a Binding conversion function
  return horiz ? "rgba(255, 221, 51, 0.55)" : "rgba(51,211,229, 0.5)";
}

function defaultFont(horiz) {  // a Binding conversion function
  return horiz ? "bold 20px sans-serif" : "bold 16px sans-serif";
}

// this function is used to highlight a Group that the selection may be dropped into
function highlightGroup(e, grp, show) {
  if (!grp) return;
  e.handled = true;
  if (show) {
    // cannot depend on the grp.diagram.selection in the case of external drag-and-drops;
    // instead depend on the DraggingTool.draggedParts or .copiedParts
    var tool = grp.diagram.toolManager.draggingTool;
    var map = tool.draggedParts || tool.copiedParts;  // this is a Map
    // now we can check to see if the Group will accept membership of the dragged Parts
    if (grp.canAddMembers(map.toKeySet())) {
      grp.isHighlighted = true;
      return;
    }
  }
  grp.isHighlighted = false;
}

// Upon a drop onto a Group, we try to add the selection as members of the Group.
// Upon a drop onto the background, or onto a top-level Node, make selection top-level.
// If this is OK, we're done; otherwise we cancel the operation to rollback everything.
function finishDrop(e, grp) {
  var ok = (grp !== null
    ? grp.addMembers(grp.diagram.selection, true)
    : e.diagram.commandHandler.addTopLevelParts(e.diagram.selection, true));
  if (!ok) e.diagram.currentTool.doCancel();
}

myDiagram.groupTemplate =
  new go.Group("Auto",
    {
      background: "blue",
      ungroupable: true,
      // highlight when dragging into the Group
      mouseDragEnter: (e, grp, prev) => highlightGroup(e, grp, true),
      mouseDragLeave: (e, grp, next) => highlightGroup(e, grp, false),
      computesBoundsAfterDrag: true,
      computesBoundsIncludingLocation: true,
      // when the selection is dropped into a Group, add the selected Parts into that Group;
      // if it fails, cancel the tool, rolling back any changes
      mouseDrop: finishDrop,
      handlesDragDropForMembers: true,  // don't need to define handlers on member Nodes and Links
      // Groups containing Groups lay out their members horizontally
      layout: makeLayout(false)
    })
    .bind("layout", "horiz", makeLayout)
    .bind(new go.Binding("background", "isHighlighted", h => h ? "rgba(255,0,0,0.2)" : "transparent").ofObject())
    .add(new go.Shape("Rectangle",
      { fill: null, stroke: defaultColor(false), fill: defaultColor(false), strokeWidth: 2 })
      .bind("stroke", "horiz", defaultColor)
      .bind("fill", "horiz", defaultColor))
    .add(
      new go.Panel("Vertical")  // title above Placeholder
        .add(new go.Panel("Horizontal",  // button next to TextBlock
          { stretch: go.GraphObject.Horizontal, background: defaultColor(false) })
          .bind("background", "horiz", defaultColor)
          .add(go.GraphObject.make("SubGraphExpanderButton", { alignment: go.Spot.Right, margin: 5 }))
          .add(new go.TextBlock(
            {
              alignment: go.Spot.Left,
              editable: true,
              margin: 5,
              font: defaultFont(false),
              opacity: 0.95,  // allow some color to show through
              stroke: "#404040"
            })
            .bind("font", "horiz", defaultFont)
            .bind("text", "text", null, null)) // `null` as the fourth argument makes this a two-way binding
        )  // end Horizontal Panel
        .add(new go.Placeholder({ padding: 5, alignment: go.Spot.TopLeft }))
    )  // end Vertical Panel


myDiagram.nodeTemplate =
  new go.Node("Auto",
    { // dropping on a Node is the same as dropping on its containing Group, even if it's top-level
      mouseDrop: (e, node) => finishDrop(e, node.containingGroup)
    })
    .add(new go.Shape("RoundedRectangle", { fill: "rgba(172, 230, 0, 0.9)", stroke: "white", strokeWidth: 0.5 }))
    .add(new go.TextBlock(
      {
        margin: 7,
        editable: true,
        font: "bold 13px sans-serif",
        opacity: 0.90
      })
      .bind("text", "text", null, null));  // `null` as the fourth argument makes this a two-way binding

// initialize the Palette and its contents
myPalette =
  new go.Palette("myPaletteDiv",
    {
      nodeTemplateMap: myDiagram.nodeTemplateMap,
      groupTemplateMap: myDiagram.groupTemplateMap
    });

myPalette.model = new go.GraphLinksModel([
  { text: "New Node", color: "#ACE600" },
  { isGroup: true, text: "H Group", horiz: true },
  { isGroup: true, text: "V Group", horiz: false }
]);

load();

    // save a model to and load a model from JSON text, displayed below the Diagram
    function save() {
      document.getElementById("mySavedModel").value = myDiagram.model.toJson();
      myDiagram.isModified = false;
    }
    function load() {
      myDiagram.model = go.Model.fromJson(document.getElementById("mySavedModel").value);
    }
  </script>
</body></html>