Dynamic Layer Spacing for Tree Layout

Hi,

I have a question in regards to dynamically spacing an individual link. I’ll give a little context but that feels like the easiest thing to do. For my project, I have a Tree Layout with layer spacing set to 100 and nodes are be dragged and dropped using the Non-Realtime dragging tool. You can drag in new nodes / reorder existing node by dropping on a link which would ultimately splice the node at the link position.

In the figma design I am trying to match, a user would see a dropzone as they drag a node over a link. What I am thinking is that on mouseDragEnter, I set isHighlighted to true and then I’ll show the dropzone panel. The one major thing stopping me from showing the dropzone is that I need the hovered link to have larger spacing during this operation. That way, I can fit the dropzone temporarily and overlay it on top of the extended link. Here is a screenshot of the design:

You can see in the screenshot that a node all the way on the right is being dragged. It is attempting to be reordered between the first two nodes, the green and pink one.

Is there anyway to stretch the link out for just the link that has a dragEnter event? Let’s say update from the default 100 I set to 250? Maybe there is something I can do in canvas to show this? I’d prefer to not create an actual node for the dropzone just to then remove it after the drag operation completes. Please let me know my options and thank you!

You could use a custom TreeLayout that overrides assignTreeVertexValues, when wanting to make more room between a parent node and its child nodes, to increase TreeVertex.layerSpacing of the vertex corresponding to the parent node.

It’s true that you don’t need to create a new Node to render the drop zone. You could use an unmodeled Part in the “Tool” Layer that you manually position and add to/remove fro the Diagram.

If I get some free time tomorrow, maybe I can create a sample for you.

Thanks for the help Walter! I’ll try the custom layout after you potentially share a sample. No problem if you can’t get to it, I’ll revisit this issue later in the week.

Here you go, a complete stand-alone sample:

<!DOCTYPE html>
<html>

<head>
  <title>Splice Node into Link with DropZone</title>
  <!-- Copyright 1998-2024 by Northwoods Software Corporation. -->
</head>

<body>
  <div style="width: 100%; display: flex; justify-content: space-between">
    <div style="display: flex; flex-direction: column; margin: 0 2px 0 0">
      <div id="myPaletteDiv" style="flex-grow: 1; width: 100px; background-color: floralwhite; border: solid 1px black"></div>
      <div id="myOverviewDiv" style="margin: 2px 0 0 0; width: 100px; height: 100px; background-color: whitesmoke; border: solid 1px black"></div>
    </div>
    <div id="myDiagramDiv" style="flex-grow: 1; height: 400px; border: solid 1px black"></div>
  </div>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="go.js"></script>
  <script src="../site/extensions/NonRealtimeDraggingTool.js"></script>
  <!-- <script src="https://unpkg.com/gojs"></script>
  <script src="https://unpkg.com/gojs/extensions/NonRealtimeDraggingTool.js"></script> -->
  <script id="code">
class DropZoneDraggingTool extends NonRealtimeDraggingTool {
  constructor(init) {
    super();
    this.subtreeOffset = new go.Point(100, 0);
    const $ = go.GraphObject.make;
    this.DropZone =
      $(go.Adornment, "Auto",
        { locationSpot: go.Spot.Center },
        $(go.Shape, "RoundedRectangle",
          { fill: "white", strokeWidth: 5, stroke: "lightblue" }),
        $(go.Panel, "Auto",
          { width: 100, height: 60, margin: 5 },
          $(go.Shape, "RoundedRectangle",
            { fill: "white", stroke: "lightblue", strokeDashArray: [4, 2] }),
          $(go.Panel, "Spot",
            $(go.Shape,
              { fill: "white", stroke: "lightgray", strokeDashArray: [4, 2], width: 30, height: 20 }),
            $(go.Shape,
              {
                alignment: new go.Spot(0.5, 0.5, 0, -10),
                geometryString: "M5 0L5 20 M0 15 L5 20 10 15",
                stroke: "lightgray", strokeWidth: 2
              })
          )
        )
      );
    this.DropZone.ensureBounds();
    // internal state
    this._subtree = null;
    this._draggedNode = null;
    if (init) Object.assign(this, init);
  }

  // User must drag a single Node
  findDraggablePart() {
    if (this.diagram.selection.count > 1) return null;
    const part = super.findDraggablePart();
    if (part instanceof go.Node) {
      this._draggedNode = part;
      return part;
    }
    return null;
  }

  // Show a DropZone if dragging over a Link (other than one connected with _draggedNode)
  doDragOver(pt, obj) {  // method override
    // find Part at PT, ignoring temporary Parts except for the DropZone (temporary because it's an Adornment)
    var trgt = this.diagram.findObjectAt(pt, x => x.part, x => x === this.DropZone || !x.layer.isTemporary);
    if (trgt instanceof go.Link) {
      const link = trgt;
      if (link.fromNode === this._draggedNode || link.toNode === this._draggedNode) return;
      this._link = link;
      if (this.DropZone.adornedPart === null) {
        const needsShift = link.routeBounds.width < this.DropZone.actualBounds.width + 100 ||
                           link.routeBounds.height < this.DropZone.actualBounds.height + 100;
        const oldskips = this.diagram.skipsUndoManager;
        this.diagram.skipsUndoManager = true;
        if (needsShift) {
          this._subtree = link.toNode.findTreeParts();
          // shift subtree rightward
          this.diagram.moveParts(this._subtree, this.subtreeOffset);
        }
        link.isHighlighted = true;
        this.DropZone.adornedObject = link;
        link.addAdornment("DropZone", this.DropZone);
        this.diagram.skipsUndoManager = oldskips;
        // if nodes were moved, the link will be re-routed later, so the Link.midPoint will only be valid later
        if (needsShift) setTimeout(() => this.DropZone.location = link.midPoint);
      }
    } else if (trgt !== this.DropZone) {
      this.cleanup();
    }
  }

  cleanup() {
    const link = this.DropZone.adornedPart;
    if (link) {
      const oldskips = this.diagram.skipsUndoManager;
      this.diagram.skipsUndoManager = true;
      if (this._subtree) {
        // shift subtree leftward
        this.diagram.moveParts(this._subtree, new go.Point(-this.subtreeOffset.x, -this.subtreeOffset.y));
        this._subtree = null;
      }
      link.isHighlighted = false;
      link.removeAdornment("DropZone");
      this.DropZone.adornedObject = null;
      this.diagram.skipsUndoManager = oldskips;
    }
  }

  // If dropped into DropZone, splice it into the corresponding Link
  // (Note, not using doDropOnto due to undo problems.
  // Overriding doMouseUp means needing "ExternalObjectsDropped" listener too,
  // duplicating some of the work.)
  doMouseUp() {  // method override
    const link = this.DropZone.adornedPart;
    const node = this._draggedNode;
    const pt = this.diagram.lastInput.documentPoint;
    const trgt = this.diagram.findObjectAt(pt, x => x.part, x => x === this.DropZone);
    if (trgt === this.DropZone) {
      this.cleanup();
      this.spliceIntoLink(link, node);
    } else {
      this.cleanup();
    }
    super.doMouseUp();
  }

  // Splice the _draggedNode into the dropped-onto Link
  spliceIntoLink(link, node) {
    if (!link || !node) return;
    const diag = this.diagram;
    if (!diag) return;
    // disconnect node being dropped (copy collection to avoid iterating over modifications)
    new go.List(node.findLinksConnected()).each(l => diag.remove(l));
    const to = link.toNode;
    const linkdata = {};
    diag.model.addLinkData(linkdata);
    const newlink = diag.findLinkForData(linkdata);
    if (newlink !== null) {
      // splice in that node
      link.toNode = node;
      newlink.fromNode = node;
      newlink.toNode = to;
    }
  }

  doDeactivate() {
    this._subtree = null;
    this._draggedNode = null;
    super.doDeactivate();
  }

  doCancel() {
    this.cleanup();
    super.doCancel();
  }
}


const $ = go.GraphObject.make;

const myDiagram =
  new go.Diagram("myDiagramDiv",
    {
      layout: new go.TreeLayout(),
      // install the replacement DraggingTool:
      draggingTool: new DropZoneDraggingTool({ duration: 400 }),
      "ExternalObjectsDropped": e => {
        const tool = e.diagram.toolManager.draggingTool;
        const pt = e.diagram.lastInput.documentPoint;
        const trgt = e.diagram.findObjectAt(pt, x => x.part, x => x === tool.DropZone);
        if (trgt === tool.DropZone) {
          const link = tool.DropZone.adornedPart;
          const node = e.diagram.selection.first();
          tool.cleanup();
          tool.spliceIntoLink(link, node);
        }
      },
      "undoManager.isEnabled": true,
      "ModelChanged": 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, "Auto",
    { locationSpot: go.Spot.Center },
    $(go.Shape, { fill: "white" },
      new go.Binding("fill", "color")),
    $(go.TextBlock, { margin: 8 },
      new go.Binding("text"))
  );

myDiagram.linkTemplate =
  $(go.Link,
    // the highlight path Shape
    $(go.Shape, { isPanelMain: true, strokeWidth: 7, stroke: "transparent" },
      // when highlighted, show this thick Shape in red
      new go.Binding("stroke", "isHighlighted", h => h ? "red" : "transparent").ofObject()),
    // the normal path Shape
    $(go.Shape, { isPanelMain: true, strokeWidth: 1.5 }),
    $(go.Shape, { toArrow: "OpenTriangle" })
  );

myDiagram.model = new go.GraphLinksModel(
  [
    { key: 1, text: "Alpha", color: "lightblue" },
    { key: 2, text: "Beta", color: "orange" },
    { key: 3, text: "Gamma", color: "lightgreen" },
    { key: 4, text: "Delta", color: "pink" },
    { key: 5, text: "Epsilon", color: "yellow" },
    { key: 6, text: "Zeta", color: "lightblue" },
    { key: 7, text: "Eta", color: "orange" },
    { key: 8, text: "Theta", color: "lightgreen" },
  ],
  [
    { from: 1, to: 2 },
    { from: 1, to: 3 },
    { from: 3, to: 4 },
    { from: 4, to: 5 },
    { from: 1, to: 6 },
    { from: 6, to: 7 },
    { from: 6, to: 8 },
  ]);

// initialize Palette
myPalette =
  new go.Palette("myPaletteDiv",
    {
      nodeTemplateMap: myDiagram.nodeTemplateMap,
      model: new go.GraphLinksModel([
        { text: "red node", color: "red" },
        { text: "green node", color: "green" },
        { text: "blue node", color: "blue" },
        { text: "orange node", color: "orange" }
      ])
    });

// initialize Overview
myOverview =
  new go.Overview("myOverviewDiv",
    {
      observed: myDiagram,
      contentAlignment: go.Spot.Center
    });  </script>
</body>

</html>

I am testing it and it looks great. I have a couple of followup questions related to the code.

I also have the ability to drag and drop html elements from outside the canvas and it seems the adornment isn’t shown for these operations. This is because the dragging tool isn’t being used for this case. What would be the solution for this case? Is it then worth it go with the custom tree layout and overriding assignTreeVertexValues?

I could technically make use of the Palette feature which would make the dragging tool all that’s needed but I’m not sure about this option yet in regards to meeting my requirements.

Second is about the following code

// if nodes were moved, the link will be re-routed later, so the Link.midPoint will only be valid later
if (this._subtree) setTimeout(() => this.DropZone.location = link.midPoint);

Given my specific example, the dropzone is a bit messed up if I don’t set a timeout to 10 ms or more, I assume that’s fine? Is more time needed depending on the size of the model?

If you want to drag-and-drop from an HTML element, you will need to (re-)implement all of the behavior, including dealing with cancellation. I suppose some of the code could be shared.

The wait time probably doesn’t matter, but for the user I believe you don’t want to wait too long.

Ok, one final question. In order for drop zones to appear when somewhat close to a link, I made the link have an extra transparent shape with a stroke width of 150. This seems to be causing an issue that wasn’t so clear to debug while using the non-realtime dragging tool. What I noticed is that, under the hood, when you move a node, it’s links still stay connected and for non-realtime, we just have an image overlayed. When I try to reorder nodes, the links get confused on which one should handle the mouse drop event. It is most of the times picking links currently being drawn rather than the one I actually am targeting.

My questions is if I can make the stroke width 1 when a connected node starts its drag operation or if links being actively drawn can have a lower z-index. Just something to make sure the targeted link is who receives the drop event everytime.

I wouldn’t call a distance of 75 (150/2) being “somewhat close to a link”. I think you’ll run into confusion with the z-ordering of links when there are more than one link connected with a node/port – the frontmost link will “win” on mouse picks. So keeping the strokeWidth of the normally “transparent” path Shape relatively narrow is to everyone’s benefit.

However I’m still unclear about the actual problem that you are trying to describe.

Ok, I don’t mind making the stroke width of a link much smaller to let’s say 10-30.

The problem I am tackling is that I need drop zones to appear when dragged nodes are relatively close enough to links. I wasn’t sure of another way to handle this issue of showing the drop zone based on proximity aside from making the link having a large transparent background of size 150. The non realtime dragging tool hid this issue that this causes meaning I wasn’t aware a size of 150 was too big of an issue but it definitely is.

The UX team is asking if I can show drop zones almost before the dragged node is even over the potentially targeted link. Basically to help guide the user.

Not even sure if this is possible so I’ve been toying with solutions to this problem. Please let me know if this is very difficult and if I need to go back to UX.

As a side note, I did see a function called findObjectsNear. Maybe this could come into play, not sure.

Yes, instead of calling Diagram.findObjectAt to find a Link or the DropZone, you could use Diagram.findObjectsNear to potentially find a bunch of Nodes and Links or the DropZone.

There a several reasonable policies for deciding which Link is best for a particular mouse point. I don’t know what would be best for your app. A simple one is to call Link.findClosestSegment on each Link and compute the distance to that link segment by calling Point.distanceLineSegmentSquared, and then choose a Link that is closest.

Ok, I got another update. Was able to make use of those helper functions. I now have a much smaller transparent width of links set to 10. And then am able to show drop zones on links based on proximity. I also implemented the same logic for external html drag and drop so it looks like I’m all set at the moment.

Thank you very much for your help Walter!