Aligned Tree Layout with additional nodes and links

Hi, we are trying to achieve building a layer of nodes that should look like this:


It is well aligned to the left, each horizontal sibling nodes are on the same level, and the main challenge is the branching and that reversed line next to “refresh” icon circles.
We’re trying to achieve it by combining Tree Layout and Parallel Layout extensions, but what we got is here:

It is not aligned, and some line connections slightly shifted.

Is there a way to build layout like on the first screen? Maybe there are more concrete examples or solutions for that?
Thank you.

I think a regular TreeLayout might work. You’ll need to make those links that loop back so that Part.isLayoutPositioned is false, Link.isTreeLink is false, and Link.routing is go.Link.AvoidsNodes, in addition to setting the Link.path’s Shape.strokeDashArray.

Separate comment: those links are really hard to see. I suggest increasing the Shape.strokeWidth.

Hi, Walter, thank you for reply, we’ve tried with your recommendations for TreeLayout , but unlucky, the tricky part is to have that “diamond”, “circles” elements to be center-aligned relatively to main nodes, but in the same way main nodes should be aligned center vertically, relative to main branch (like on first screenshot), also “return” dashed arrow to not shift any nodes.

We end up by founding this example Flowgrammer, which in general is a perfect representation of our data flow. What we did is tried to add those small elements, but need an assistance to align them properly.

here is the source code:

<!DOCTYPE html>
<html lang="en">

<head>
</head>

<body>
	<div class="md:flex flex-col md:flex-row md:min-h-screen w-full max-w-screen-xl mx-auto">
		<div id="navSide"
			 class="flex flex-col w-full md:w-48 text-gray-700 bg-white flex-shrink-0"></div>
		<!-- * * * * * * * * * * * * * -->
		<!-- Start of GoJS sample code -->

		<script src="https://gojs.net/latest/release/go.js"></script>
		<script src="https://gojs.net/latest/extensions/ParallelLayout.js"></script>

		<div class="p-4 w-full">

			<style>
				/* Use a Flexbox to make the Palette/Overview/Diagram responsive and size things relatively */
				#myFlexDiv {
					display: flex;
					width: 100%;
					height: 95vh;
				}

				#myPODiv {
					display: flex;
				}

				@media (min-width: 768px) {
					#myFlexDiv {
						flex-flow: row;
					}

					#myPODiv {
						width: 105px;
						height: 100%;
						margin-right: 3px;
						flex-flow: column;
					}

					#myPaletteDiv {
						height: 80%;
					}

					#myOverviewDiv {
						margin-top: 3px;
						flex: 1;
					}

					#myDiagramDiv {
						flex: 1;
					}
				}

				@media (max-width: 767px) {
					#myFlexDiv {
						flex-flow: column;
						align-items: center;
					}

					#myPODiv {
						width: 90%;
						height: 105px;
						margin-bottom: 3px;
						flex-flow: row;
					}

					#myPaletteDiv {
						width: 75%;
					}

					#myOverviewDiv {
						margin-left: 3px;
						flex: 1;
					}

					#myDiagramDiv {
						width: 90%;
						flex: 1;
					}
				}
			</style>
			<script>
				// two custom figures, for "For Each" loops
				go.Shape.defineFigureGenerator("ForEach", function (shape, w, h) {
					var param1 = shape ? shape.parameter1 : NaN; // length of triangular area in direction that it is pointing
					if (isNaN(param1)) param1 = 10;
					var d = Math.min(h / 2, param1);
					var geo = new go.Geometry();
					var fig = new go.PathFigure(w, h - d, true);
					geo.add(fig);
					fig.add(new go.PathSegment(go.PathSegment.Line, w / 2, h));
					fig.add(new go.PathSegment(go.PathSegment.Line, 0, h - d));
					fig.add(new go.PathSegment(go.PathSegment.Line, 0, 0));
					fig.add(new go.PathSegment(go.PathSegment.Line, w, 0).close());
					geo.spot1 = go.Spot.TopLeft;
					geo.spot2 = new go.Spot(1, 1, 0, Math.min(-d + 2, 0));
					return geo;
				});

				go.Shape.defineFigureGenerator("EndForEach", function (shape, w, h) {
					var param1 = shape ? shape.parameter1 : NaN; // length of triangular area in direction that it is pointing
					if (isNaN(param1)) param1 = 10;
					var d = Math.min(h / 2, param1);
					var geo = new go.Geometry();
					var fig = new go.PathFigure(w, d, true);
					geo.add(fig);
					fig.add(new go.PathSegment(go.PathSegment.Line, w, h));
					fig.add(new go.PathSegment(go.PathSegment.Line, 0, h));
					fig.add(new go.PathSegment(go.PathSegment.Line, 0, d));
					fig.add(new go.PathSegment(go.PathSegment.Line, w / 2, 0).close());
					geo.spot1 = new go.Spot(0, 0, 0, Math.min(d, 0));
					geo.spot2 = go.Spot.BottomRight;
					return geo;
				});

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

					// initialize main Diagram
					myDiagram =
						$(go.Diagram, "myDiagramDiv",
							{
								allowMove: false,
								allowCopy: false,
								"SelectionDeleting": function (e) {  // before a delete happens
									// handle deletions by excising the node and reconnecting the link where the node had been
									new go.List(e.diagram.selection).each(function (part) { deletingNode(part); });
								},
								initialContentAlignment: go.Spot.Left,
								layout: $(ParallelLayout,
									{
										angle: 90,
										layerSpacing: 40,
										nodeSpacing: 60,
										alignment: go.TreeLayout.AlignmentStart,
										setsPortSpot: false,
									}
								),
								"ExternalObjectsDropped": function (e) {  // handle drops from the Palette
									var newnode = e.diagram.selection.first();
									if (!newnode) return;
									if (!(newnode instanceof go.Group) && newnode.linksConnected.count === 0) {
										// when the selection is dropped but not hooked up to the rest of the graph, delete it
										e.diagram.removeParts(e.diagram.selection, false);
									} else {
										e.diagram.commandHandler.scrollToPart(newnode);
									}
								},
								"undoManager.isEnabled": true
							});

					// dragged nodes are translucent so that the user can see highlighting of links and nodes
					myDiagram.findLayer("Tool").opacity = 0.5;

					// some common styles for most of the node templates
					function nodeStyle() {
						return {
							deletable: false,
							locationSpot: go.Spot.Center,
							mouseDragEnter: function (e, node) {
								var sh = node.findObject("SHAPE");
								if (sh) sh.fill = "lime";
							},
							mouseDragLeave: function (e, node) {
								var sh = node.findObject("SHAPE");
								if (sh) sh.fill = "white";
							},
							mouseDrop: dropOntoNode
						};
					}

					function shapeStyle() {
						return { name: "SHAPE", fill: "white" };
					}

					function textStyle() {
						return [
							{ name: "TEXTBLOCK", textAlign: "center", editable: true },
							new go.Binding("text").makeTwoWay()
						];
					}

					const makePort = (
						name,
						align,
						fromSpot,
						output,
						input,
						color
					) => {

						const props = {
							fill: color || 'transparent',
							stroke: color || 'black',
							strokeWidth: 1,
							desiredSize: new go.Size(2, 2),
							portId: name,
							alignment: align,
							fromSpot: align,
							toSpot: align,
						};

						return $(go.Shape,
							props
						);
					};

					const makePorts = () => {
						return [
							makePort('T', go.Spot.Top, go.Spot.Top, true, true, 'red'),
							makePort('L', go.Spot.Left, go.Spot.Left, true, true, 'gold'),
							makePort('R', go.Spot.Right, go.Spot.Right, true, true, 'cyan'),
							makePort('B', go.Spot.Bottom, go.Spot.Bottom, true, true, 'magenta')
						]
					}

					// define the Node templates
					myDiagram.nodeTemplate =  // regular action steps
						$(go.Node, "Auto", nodeStyle(),
							{ deletable: true },  // override nodeStyle()
							{ minSize: new go.Size(250, 80) },
							$(go.Shape, shapeStyle()),
							$(go.TextBlock, textStyle(),
								// { margin: 4 }
							)
						);

					myDiagram.nodeTemplateMap.add("Start",
						$(go.Node, "Auto", nodeStyle(),
							{ desiredSize: new go.Size(250, 80) },
							$(go.Shape, "Rectangle", shapeStyle()),
							$(go.TextBlock, textStyle(), "Start")
						));

					myDiagram.nodeTemplateMap.add("End",
						$(go.Node, "Auto", nodeStyle(),
							{ desiredSize: new go.Size(250, 80) },
							$(go.Shape, "Rectangle", shapeStyle()),
							$(go.TextBlock, textStyle(), "End")
						));

					myDiagram.nodeTemplateMap.add("For",
						$(go.Node, "Auto", nodeStyle(),
							{ minSize: new go.Size(250, 80) },
							$(go.Shape, "ForEach", shapeStyle()),
							$(go.TextBlock, textStyle(), "For Each",
								// { margin: 4 }
							)
						));

					myDiagram.nodeTemplateMap.add("ForPoint",
						$(go.Node, "Auto", nodeStyle(),
							{ minSize: new go.Size(40, 40), portId: '' },
							$(go.Shape, "Circle", shapeStyle()),
							$(go.TextBlock, textStyle(), "R"),

							makePorts()
						));

					myDiagram.nodeTemplateMap.add("EndFor",
						$(go.Node, nodeStyle(),
							$(go.Shape, "EndForEach", shapeStyle(),
								{ desiredSize: new go.Size(4, 4) })
						));

					myDiagram.nodeTemplateMap.add("While",
						$(go.Node, "Auto", nodeStyle(),
							{ minSize: new go.Size(250, 80) },
							$(go.Shape, "ForEach", shapeStyle(),
								{ angle: -90, spot2: new go.Spot(1, 1, -6, 0) }),
							$(go.TextBlock, textStyle(), "While",
								// { margin: 4 }
							)
						));

					myDiagram.nodeTemplateMap.add("EndWhile",
						$(go.Node, nodeStyle(),
							$(go.Shape, "Circle", shapeStyle(),
								{ desiredSize: new go.Size(4, 4) })
						));

					myDiagram.nodeTemplateMap.add("If",
						$(go.Node, "Auto", nodeStyle(),
							{ minSize: new go.Size(250, 80) },
							$(go.Shape, "Diamond", shapeStyle()),
							$(go.TextBlock, textStyle(), "If")
						));


					myDiagram.nodeTemplateMap.add("EndIf",
						$(go.Node, nodeStyle(),
							$(go.Shape, "Diamond", shapeStyle(),
								{
									desiredSize: new go.Size(4, 4)
								}
							)
						));

					myDiagram.nodeTemplateMap.add("Switch",
						$(go.Node, "Auto", nodeStyle(),
							{ minSize: new go.Size(250, 80) },
							$(go.Shape, "TriangleUp", shapeStyle()),
							$(go.TextBlock, textStyle(), "Switch")
						));

					myDiagram.nodeTemplateMap.add("StartSwitch",
						$(go.Node, "Auto", nodeStyle(),
							{ minSize: new go.Size(40, 40) },
							$(go.Shape, "Diamond", shapeStyle()),
							$(go.TextBlock, textStyle(), "StartSwitch"),
							makePorts()
						));

					myDiagram.nodeTemplateMap.add("Merge",
						$(go.Node, nodeStyle(),
							$(go.Shape, "TriangleDown", shapeStyle(),
								{ desiredSize: new go.Size(4, 4) })
						));

					function groupColor(cat) {
						switch (cat) {
							case "If": return "rgba(255,0,0,0.25)";
							case "For": return "rgba(0,255,0,0.25)";
							case "While": return "rgba(0,0,255,0.25)";
							default: return "rgba(0,0,0,0.25)";
						}
					}

					// define the Group template, required but unseen
					myDiagram.groupTemplate =
						$(go.Group, "Auto",
							{
								locationSpot: go.Spot.Center,
								// avoidableMargin: 30,  // extra space on the sides
								layout: $(ParallelLayout,
									{
										angle: 90,
										layerSpacing: 40,
										nodeSpacing: 60,
										alignment: go.TreeLayout.AlignmentStart,
										// setsPortSpot: false,
									}
								),
								mouseDragEnter: function (e, group) {
									var sh = group.findObject("SHAPE");
									if (sh) { sh.width = Math.max(20, group.actualBounds.width - 20); sh.stroke = "lime"; }
								},
								mouseDragLeave: function (e, group) {
									var sh = group.findObject("SHAPE");
									if (sh) sh.stroke = null;
								},
								mouseDrop: dropOntoNode
							},
							$(go.Shape, "RoundedRectangle",
								{ fill: "rgba(0,0,0,0.55)", strokeWidth: 0, spot1: go.Spot.TopLeft, spot2: go.Spot.BottomRight },
								new go.Binding("fill", "cat", groupColor)),
							$(go.Placeholder),
							$(go.Shape, "LineH",
								{
									name: "SHAPE",
									height: 0, alignment: go.Spot.Bottom,
									stroke: null, strokeWidth: 8
								})
						);

					myDiagram.linkTemplate =
						$(go.Link,
							{
								selectable: false,
								deletable: false,
								// routing: go.Link.Orthogonal,
								routing: go.Link.AvoidsNodes,
								corner: 1,
								toShortLength: 2,
								// links cannot be deleted
								// If a node from the Palette is dragged over this node, its outline will turn green
								mouseDragEnter: function (e, link) { if (!isLoopBack(link)) link.isHighlighted = true; },
								mouseDragLeave: function (e, link) { link.isHighlighted = false; },
								// if a node from the Palette is dropped on a link, the link is replaced by links to and from the new node
								mouseDrop: dropOntoLink,

							},

							// new go.Binding("fromSpot", "fromSpot", go.Spot.parse),
							// new go.Binding("toSpot", "toSpot", go.Spot.parse),

							$(go.Shape, { isPanelMain: true, stroke: "transparent", strokeWidth: 8 },
								new go.Binding("stroke", "isHighlighted", function (h) { return h ? "lime" : "transparent"; }).ofObject()),
							$(go.Shape,
								{ isPanelMain: true, stroke: "black", strokeWidth: 1.5, },
								new go.Binding('strokeDashArray', '', l => l.dash ? [2, 2] : undefined)
							),
							$(go.Shape, { toArrow: "Standard", strokeWidth: 0 }),
							// $(go.TextBlock, { segmentIndex: -2, segmentFraction: 0.75, editable: true },
							//   new go.Binding("text").makeTwoWay(),
							//   new go.Binding("background", "text", function(t) { return t ? "white" : null; }))
						);

					function isLoopBack(link) {
						if (!link) return false;
						if (link.fromNode.containingGroup !== link.toNode.containingGroup) return false;
						var cat = link.fromNode.category;
						return (cat === "EndFor" || cat === "EndWhile" || cat === "EndIf");
					}

					// A node dropped onto a Merge node is spliced into a link coming into that node;
					// otherwise it is spliced into a link that is coming out of that node.
					function dropOntoNode(e, oldnode) {
						if (oldnode instanceof go.Group) {
							var merge = oldnode.layout.mergeNode;
							if (merge) {
								var it = merge.findLinksOutOf();
								while (it.next()) {
									var link = it.value;
									if (link.fromNode.containingGroup !== link.toNode.containingGroup) {
										dropOntoLink(e, link);
										break;
									}
								}
							}
						} else if (oldnode instanceof go.Node) {
							var cat = oldnode.category;
							if (cat === "Merge" || cat === "End" || cat === "EndFor" || cat === "EndWhile" || cat === "EndIf") {
								var link = oldnode.findLinksInto().first();
								if (link) dropOntoLink(e, link);
							} else {
								var link = oldnode.findLinksOutOf().first();
								if (link) dropOntoLink(e, link);
							}
						}
					}

					// Splice a node into a link.
					// If the new node is of category "For" or "While" or "If", create a Group and splice it in,
					// and add the new node to that group, and add any other desired nodes and links to that group.
					function dropOntoLink(e, oldlink) {
						if (!(oldlink instanceof go.Link)) return;
						var diagram = e.diagram;
						var newnode = diagram.selection.first();
						if (!(newnode instanceof go.Node)) return;
						if (!newnode.isTopLevel) return;
						if (isLoopBack(oldlink)) {
							// can't add nodes into links going back to the "For" node
							diagram.remove(newnode);
							return;
						}

						var fromnode = oldlink.fromNode;
						var tonode = oldlink.toNode;
						if (newnode.category === "") {  // add simple step into chain of actions
							newnode.containingGroup = oldlink.containingGroup;
							// Reconnect the existing link to the new node
							oldlink.toNode = newnode;
							// Then add links from the new node to the old node
							if (newnode.category === "If") {
								diagram.model.addLinkData({ from: newnode.key, to: tonode.key });
								diagram.model.addLinkData({ from: newnode.key, to: tonode.key });
							} else {
								diagram.model.addLinkData({ from: newnode.key, to: tonode.key });
							}
						} else if (newnode.category === "For" || newnode.category === "While") {  // add loop group
							// add group for loop
							var groupdata = { isGroup: true, cat: newnode.category };
							diagram.model.addNodeData(groupdata);
							var group = diagram.findNodeForData(groupdata);
							group.containingGroup = oldlink.containingGroup;
							diagram.select(group);

							newnode.containingGroup = group;

							var p1data = { category: "ForPoint" };
							diagram.model.addNodeData(p1data);
							var p1node = diagram.findNodeForData(p1data);
							p1node.containingGroup = group;
							p1node.location = e.documentPoint;

							// var adata = { category: "" };
							// diagram.model.addNodeData(adata);
							// var anode = diagram.findNodeForData(adata);
							// anode.containingGroup = group;
							// anode.location = e.documentPoint;

							var p2data = { category: "ForPoint" };
							diagram.model.addNodeData(p2data);
							var p2node = diagram.findNodeForData(p2data);
							p2node.containingGroup = group;
							p2node.location = e.documentPoint;

							var enddata = { category: "End" + newnode.category };
							diagram.model.addNodeData(enddata);
							var endnode = diagram.findNodeForData(enddata);
							endnode.containingGroup = group;
							endnode.location = e.documentPoint;

							diagram.model.addLinkData({ from: newnode.key, to: p1node.key });

							diagram.model.addLinkData({ from: p1node.key, to: p2node.key });
							// diagram.model.addLinkData({ from: p1node.key, to: anode.key });
							// diagram.model.addLinkData({ from: anode.key, to: p2node.key });

							diagram.model.addLinkData({ from: p2node.key, to: endnode.key, fromPort: 'B' });


							// diagram.model.addLinkData({ from: newnode.key, to: endnode.key });
							// diagram.model.addLinkData({ from: endnode.key, to: newnode.key }); // TODO: Restore back link

							diagram.model.addLinkData({ from: p2node.key, to: p1node.key, dash: true, fromPort: 'R', toPort: 'R' }); // Loop back

							// Reconnect the existing link to the new node
							oldlink.toNode = newnode;
							// Then add a link from the end node to the old node
							diagram.model.addLinkData({ from: endnode.key, to: tonode.key });
						} else if (newnode.category === "If") {  // add Conditional group
							// add group for conditional
							var groupdata = { isGroup: true, cat: newnode.category };
							diagram.model.addNodeData(groupdata);
							var group = diagram.findNodeForData(groupdata);
							group.containingGroup = oldlink.containingGroup;
							diagram.select(group);

							newnode.containingGroup = group;

							// var startdata = { category: 'IfStart' };
							// diagram.model.addNodeData(startdata);
							// var startnode = diagram.findNodeForData(startdata);
							// startnode.containingGroup = group;
							// startnode.location = e.documentPoint;
							// diagram.model.addLinkData({ from: newnode.key, to: startnode.key, text: "IfStart" });

							var enddata = { category: "EndIf" };
							diagram.model.addNodeData(enddata);
							var endnode = diagram.findNodeForData(enddata);
							endnode.containingGroup = group;
							endnode.location = e.documentPoint;

							var truedata = { from: newnode.key, to: endnode.key, text: "true" };
							diagram.model.addLinkData(truedata);
							var truelink = diagram.findLinkForData(truedata);

							var falsedata = { from: newnode.key, to: endnode.key, text: "false" };
							diagram.model.addLinkData(falsedata);
							var falselink = diagram.findLinkForData(falsedata);

							// Reconnect the existing link to the new node
							oldlink.toNode = newnode;
							// Then add a link from the new node to the old node
							diagram.model.addLinkData({ from: endnode.key, to: tonode.key });
						} else if (newnode.category === "Switch") {  // add multi-way Switch group
							// add group for loop
							var groupdata = { isGroup: true, cat: newnode.category };
							diagram.model.addNodeData(groupdata);
							var group = diagram.findNodeForData(groupdata);
							group.containingGroup = oldlink.containingGroup;
							diagram.select(group);

							newnode.containingGroup = group;

							var startdata = { category: 'StartSwitch' };
							diagram.model.addNodeData(startdata);
							var startnode = diagram.findNodeForData(startdata);
							startnode.containingGroup = group;
							startnode.location = e.documentPoint;
							diagram.model.addLinkData({ from: newnode.key, to: startnode.key, text: "StartSwitch", toPort: 'T' });

							var enddata = { category: "Merge" };
							diagram.model.addNodeData(enddata);
							var endnode = diagram.findNodeForData(enddata);
							endnode.containingGroup = group;
							endnode.location = e.documentPoint;

							var yesdata = { text: "yes,\ndo it" };
							diagram.model.addNodeData(yesdata);
							var yesnode = diagram.findNodeForData(yesdata);
							yesnode.containingGroup = group;
							yesnode.location = e.documentPoint;
							diagram.model.addLinkData({ from: startdata.key, to: yesnode.key, text: "yes", fromPort: 'B' });
							diagram.model.addLinkData({ from: yesnode.key, to: endnode.key });

							var nodata = { text: "no,\ndon't" };
							diagram.model.addNodeData(nodata);
							var nonode = diagram.findNodeForData(nodata);
							nonode.containingGroup = group;
							nonode.location = e.documentPoint;
							diagram.model.addLinkData({ from: startdata.key, to: nonode.key, text: "no", fromPort: 'R' });
							diagram.model.addLinkData({ from: nonode.key, to: endnode.key });

							var maybedata = { text: "??" };
							diagram.model.addNodeData(maybedata);
							var maybenode = diagram.findNodeForData(maybedata);
							maybenode.containingGroup = group;
							maybenode.location = e.documentPoint;
							diagram.model.addLinkData({ from: startdata.key, to: maybenode.key, text: "maybe", fromPort: 'R' });
							diagram.model.addLinkData({ from: maybenode.key, to: endnode.key });

							var xdata = { text: "x" }; // 4th test option
							diagram.model.addNodeData(xdata);
							var xnode = diagram.findNodeForData(xdata);
							xnode.containingGroup = group;
							xnode.location = e.documentPoint;
							diagram.model.addLinkData({ from: startdata.key, to: xnode.key, text: "x", fromPort: 'R' });
							diagram.model.addLinkData({ from: xnode.key, to: endnode.key });

							// Reconnect the existing link to the new node
							oldlink.toNode = newnode;
							// Then add a link from the end node to the old node
							diagram.model.addLinkData({ from: endnode.key, to: tonode.key });
						}
						diagram.layoutDiagram(true);
					}

					function deletingNode(node) {  // excise node from the chain that it is in
						if (!(node instanceof go.Node)) return;
						if (node instanceof go.Group) {
							var externals = node.findExternalLinksConnected();
							var next = null;
							externals.each(function (link) {
								if (link.fromNode.isMemberOf(node)) next = link.toNode;
							});
							if (next) {
								externals.each(function (link) {
									if (link.toNode.isMemberOf(node)) link.toNode = next;
								});
							}
						} else if (node.category === "") {
							var next = node.findNodesOutOf().first();
							if (next) {
								new go.List(node.findLinksInto()).each(function (link) { link.toNode = next; });
							}
						}
					}

					// initialize Palette
					myPalette =
						$(go.Palette, "myPaletteDiv",
							{
								maxSelectionCount: 1,
								nodeTemplateMap: myDiagram.nodeTemplateMap,
								model: new go.GraphLinksModel([
									{ text: "Action" },
									{ text: "For Each", category: "For" },
									{ text: "While", category: "While" },
									{ text: "If", category: "If" },
									{ text: "Switch", category: "Switch" }
								])
							});

					// initialize Overview
					myOverview =
						$(go.Overview, "myOverviewDiv",
							{
								observed: myDiagram,
								contentAlignment: go.Spot.Center
							});

					load();
				}

				// Show the diagram's model in JSON format
				function save() {
					document.getElementById("mySavedModel").value = myDiagram.model.toJson();
					myDiagram.isModified = false;
				}
				function load() {
					myDiagram.model = go.Model.fromJson(document.getElementById("mySavedModel").value);
					myDiagram.model.linkFromPortIdProperty = 'fromPort';
					myDiagram.model.linkToPortIdProperty = 'toPort';
				}

				function newDiagram() {
					myDiagram.model = go.GraphObject.make(go.GraphLinksModel,
						{
							nodeDataArray:
								[
									{ "key": 1, "text": "S", "category": "Start" },
									{ "key": 2, "text": "E", "category": "End" }
								],
							linkDataArray:
								[
									{ "from": 1, "to": 2 }
								]
						});
				}

				window.addEventListener('DOMContentLoaded', init);
			</script>

			<div id="sample">
				<div id="myFlexDiv">
					<div id="myPODiv">
						<div id="myPaletteDiv"
							 style="background-color: floralwhite; border: solid 1px black"></div>
						<div id="myOverviewDiv"
							 style="background-color: whitesmoke; border: solid 1px black"></div>
					</div>
					<div id="myDiagramDiv"
						 style="border: solid 1px black"></div>
				</div>
				<div id="buttons">
					<button id="loadModel"
							onclick="load()">Load</button>
					<button id="saveModel"
							onclick="save()">Save</button>
					<button onclick="newDiagram()">New Diagram</button>
				</div>
				<textarea id="mySavedModel"
						  style="width:100%;height:200px">
{ "class": "GraphLinksModel",
  "nodeDataArray": [
{"key":1, "text":"S", "category":"Start"},
{"key":13, "text":"E", "category":"End"}
 ],
  "linkDataArray": [
  {"from":1, "to":13}
 ]}
  </textarea>

				<!-- Backup Nodes -->
				<!-- 
	  {"key":1, "text":"S", "category":"Start"},
{"key":-1, "isGroup":true, "cat":"For"},
{"key":2, "text":"For Each", "category":"For", "group":-1},
{"key":3, "text":"Action 1", "group":-1},
{"key":-2, "isGroup":true, "cat":"If", "group":-1},
{"key":4, "text":"If", "category":"If", "group":-2},
{"key":5, "text":"Action 2", "group":-2},
{"key":6, "text":"Action 3", "group":-2},
{"key":-3, "isGroup":true, "cat":"For", "group":-2},
{"key":7, "text":"For Each\n(nested)", "category":"For", "group":-3},
{"key":8, "text":"Action 4", "group":-3},
{"key":9, "text":"", "category":"EndFor", "group":-3},
{"key":10, "text":"", "category":"EndIf", "group":-2},
{"key":11, "text":"Action 5", "group":-1},
{"key":12, "text":"", "category":"EndFor", "group":-1},
{"key":13, "text":"E", "category":"End"}
 -->

				<!-- BackUpLinks -->
				<!-- {"from":1, "to":2},
{"from":2, "to":3},
{"from":3, "to":4},
{"from":4, "to":5, "text":"true"},
{"from":4, "to":6, "text":"false"},
{"from":6, "to":7},
{"from":7, "to":8},
{"from":8, "to":9},
{"from":5, "to":10},
{"from":9, "to":10},
{"from":9, "to":7},
{"from":10, "to":11},
{"from":11, "to":12},
{"from":12, "to":2, "isTreeLink": false },
{"from":12, "to":13} -->

			</div>
		</div>
		<!-- * * * * * * * * * * * * * -->
		<!--  End of GoJS sample code  -->
	</div>
</body>

</html>

How to align those elements? Can it be feasible by some settings or we need to implement custom computation?


example 1

example 2

How to make link go out of port in defined direction? Even if port on the left or right side, link is going from bottom?
To make these look like those:
Ports outgoing directions



Arrow shifting the whole group content (should be just a decoration)

Appreciate any help

I’ll look into this later today.

I think part of the problem is that you are trying to add multiple ports to the nodes, where as the Flowgrammer sample, that I assume your code was derived from, does not use multiple ports.

As you can see in Flowgrammer, the contents of the loops (i.e. the subgraphs in groups) are lined up horizontally in the middle of the group.

I found when using the go-debug.js library that you had an error in your binding. This fixes all of the binding errors:

new go.Binding('strokeDashArray', '', l => l.dash ? [2, 2] : null)

Also, I tried modifying the Flowgrammer sample so that dropping a “While” node would automatically create a loop with two “Action” nodes in them. You can do that by something like this code in dropOntoLink:

        } else if (newnode.category === "For" || newnode.category === "While") {  // add loop group
          // add group for loop
          var groupdata = { isGroup: true, cat: newnode.category };
          diagram.model.addNodeData(groupdata);
          var group = diagram.findNodeForData(groupdata);
          group.containingGroup = oldlink.containingGroup;
          diagram.select(group);

          newnode.containingGroup = group;

          var enddata = { category: "End" + newnode.category };
          diagram.model.addNodeData(enddata);
          var endnode = diagram.findNodeForData(enddata);
          endnode.containingGroup = group;
          endnode.location = e.documentPoint;

          var stepdata1 = { text: "step 1" };
          diagram.model.addNodeData(stepdata1);
          var stepnode1 = diagram.findNodeForData(stepdata1);
          stepnode1.containingGroup = group;
          stepnode1.location = e.documentPoint;
          diagram.model.addLinkData({ from: newnode.key, to: stepnode1.key });

          var stepdata2 = { text: "step 2" };
          diagram.model.addNodeData(stepdata2);
          var stepnode2 = diagram.findNodeForData(stepdata2);
          stepnode2.containingGroup = group;
          stepnode2.location = e.documentPoint;
          diagram.model.addLinkData({ from: stepnode1.key, to: stepnode2.key });

          diagram.model.addLinkData({ from: stepnode2.key, to: endnode.key });
          diagram.model.addLinkData({ from: endnode.key, to: newnode.key });

          // Reconnect the existing link to the new node
          oldlink.toNode = newnode;
          // Then add a link from the end node to the old node
          diagram.model.addLinkData({ from: endnode.key, to: tonode.key });

The result:

Yes we took Flowgrammer as a source, because it is the closest representation of data flow and nodes/groups structures we want. The 2 main features we are concentrated at are Switch and ForEach (also If, but it is just a Switch with 2 options).

First we want to achieve is layout. In Flowgrammer all the nested branches are outgoing to the sides:
image
we want to align it to the left, so all the conditions will go to the right side:


and set alignment: go.TreeLayout.AlignmentStart for main and group layouts, which makes it looks exactly what we need, but it makes also smaller nodes (like R-circles in ForEach, or diamond in Switch) also align to the left edge. So the first question is how to align those smaller nodes to be aligned center of the parent:

In the sources of the ParallelLayout.js I saw some custom computations inside makeNetwork and commitNodes, do we need to implement positioning there?

Also, just to clarify what those elements are, comparing to Flowgrammer, the connection to the children starts not from the main node, but from the middle “connector” node (a decoration if you wish). For example Switch


and ForEach, in Flowgrammer recycle goes right to the main node, but in our case it is going from two decoration circles which be placed right after main node and at the end of the group

And that is pretty much of it and all the possible combinations shown in the 1st post on the very top image

I’m not sure that I understand how one decides whether the nodes should align on the left or in the middle. I suppose you could set the TreeLayout.alignment to AlignmentStart for the Diagram.layout but not the one that is the Group.layout.

Do you know when a Link is going to “go back” between two “decoration circles”? You probably do because you know when to use Shape.strokeDashArray to make the link path dotted. In that case you can set Link.fromSpot to go.Spot.Right and Link.toSpot to go.Spot.Right. Something like this in your link template:

					new go.Binding("fromSpot", "dash", d => go.Spot.Right),
					new go.Binding("toSpot", "dash", d => go.Spot.Right),

If you don’t want people to drop nodes onto the link between the “For Each” main node and the top decoration circle, you can detect that in the mouseDragEnter and mouseDrop event handlers and decide what to (not?) do.

Setting TreeLayout.alignment to AlignmentStart just for diagram and not for the group make aligning inside Switch and ForEach groups centered (guess that is Parallel Layout default).
Also adding from/to spot didnt have any effect, still going out from bottom and to the top, seems like Tree layout forced behavior:
image
Here is the result


how it expected to be:

Did you want the subgraph to be laid out centered only if the group represents a “For Each” and not for other kinds of groups?

You can do that by using a different ParallelLayout for “For Each” groups. That can be accomplished either using a separate group template or by binding the Group.layout property to an instance of the layout that you want to use.

Ok, by combining any setting we cant achieve alignment of “decoration” connection points (like StartSwitch rhombus and R circles), so we added some more lines of code to a ParallelLayout.js, and here what we got:
commitNodes



Now for each recycle points perfectly aligned in center relative to upper ForEach node, and group itself is aligned in main thread,
Here is the switch
image

rhombus also aligned to center relative to upper Switch main node, even nested.
All looks good so far.

But here is the issue with recycle link if content of ForEach is too wide:


This link now tries to be drawn on the left, thus group gets a left padding which pushes group to the right and left alignment brakes.
Is there a solution to either make link be “out of group” or to force it to be drawn on the right side?
Here is the current dashed link bindings:
image

Set Group.computesBoundsIncludingLinks Group | GoJS API to false.

Yes, computesBoundsIncludingLinks is worked, now link is out of group as expected, but if there are couple for each loops, links are overlapping:

Well, you could set Group.computesBoundsIncludingLinks back to true. :-)

But if you don’t want to do that you’ll need to customize the routing. Try something like this:

  function FlowchartLink() {
    go.Link.call(this);
  }
  go.Diagram.inherit(FlowchartLink, go.Link);

  FlowchartLink.prototype.computeEndSegmentLength = function(node, port, spot, from) {
    // calculate based on distance between ports
    var otherport = this.getOtherPort(port);
    if (otherport !== null && this.isOrthogonal && (spot.equals(go.Spot.Right) || spot.equals(go.Spot.Left))) {
      // assume the link is basically vertical with horizontal end segments
      var fp = port.getDocumentPoint(go.Spot.Center);
      var tp = otherport.getDocumentPoint(go.Spot.Center);
      var dist = Math.abs(tp.y - fp.y);
      // end segment length is proportional to vertical distance between ports,
      // plus some randomization based on Y position
      return Math.max(10, Math.floor(Math.sqrt(dist + fp.y%123)));
    }
    return go.Link.prototype.computeEndSegmentLength.call(this, node, port, spot, from);
  };

I don’t have time to try this right now, but I think this kind of strategy will work. You may need to fiddle with the constants to get the results that you like.

We’ve tried your solution and also similar one to build link from skratch:


result is same, square link that is going outside of its own block and not intersects with inner content, but because of Group.computesBoundsIncludingLinks set to false it is not pushing content outside of it, like here:

I thought you did not want the groups to be shifted due to how the links were routed.

Could you please explain your screenshot?

Groups and nodes can be shifted to content to be fit without intersections, the only requirement is to keep main and first in groups branches straight vertical and centered relative to branch:


As we can see main branch and subgroup main branches (the left one) is straight vertical and aligned.

In this example it is also aligned and recycle links are drawing on the right and not intersects, which is great.

In this case also everything is good, recycle links are not intersecting and the whole branch it is located on shifts one on the right, which is great.

All that is with Group.computesBoundsIncludingLinks set to true

But if a wide group added to ForEach, recycle link is drawing on the left, which is cause misplacement of the whole branch, like on the screen below

Is there a way to tell link to forced draw on the right side? Thus there will be no gap on the left, and content will be aligned

Since your Link.routing is go.Link.AvoidsNodes, the route that is chosen is a shortest one that it can find that does not cross over any nodes. In your last screenshot, the nested “Switch” group is really wide, so the shortest route is on the left side of it.

But if the route were allowed to cross over the group, I think it could be better. Set Group.avoidable to false.

However, that will not guarantee that the route does not go on the left side. It will depend on the widths of the member nodes that are lined up inside the group. Off hand I don’t know if there is an easy solution for that.

Setting avoidable to true of group causes link go through group:

Meanwhile we’ve tried another solution with combination of computesBoundsIncludingLinks: true and using custom link computation:


What this doing is taking just start and end point, erase points of link completely and created 4 points from scratch. Now it is pushing content on the right side and dont intersect.

The thing is we are using containingGroup.naturalBounds which means that on every node insertion/deletion, a newer naturalBounds will be wider, because of existing “out-of-bounds” recycle link, and link becomes farther and farther, here is the sequence:

Is there a way to stabilize that?