The complexity here comes from trying to treat “Node 2” or “Beta” both as a simple Node and as a Group. In some respects, for example the links to and from “Beta”, you want to treat it just as a simple Node. But we want the layout to treat “Beta” as a Group, so that when it is expanded it occupies the whole area of the Group, not just the “Beta” Node.
Although some other layouts are sophisticated enough to handle groups, SerpentineLayout is not. So I’ve had to modify it to handle what you want. Both the modified sample and the modified SerpentineLayout are appended here. First, the result:
The adapted sample:
<!DOCTYPE html>
<html><body>
<script src="https://unpkg.com/gojs"></script>
<script src="SerpentineLayout2.js"></script>
<div id="sample">
<div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:500px; min-width: 200px"></div>
</div>
<script id="code">
var $ = go.GraphObject.make;
myDiagram =
$(go.Diagram, "myDiagramDiv", // create a Diagram for the DIV HTML element
{
layout: $(SerpentineLayout, { isRealtime: false }) // defined in SerpentineLayout.js
});
myDiagram.nodeTemplate =
$(go.Node, "Auto",
$(go.Shape,
{ figure: "RoundedRectangle", fill: "white" },
new go.Binding("fill", "color")),
$(go.Panel, "Horizontal",
$(go.TextBlock,
{ margin: 4 },
new go.Binding("text")),
$("TreeExpanderButton")
)
);
myDiagram.linkTemplate =
$(go.Link,
{ routing: go.Link.AvoidsNodes, corner: 5 },
new go.Binding("isTreeLink", "isTopLevel", t => !t).ofObject(),
$(go.Shape),
$(go.Shape, { toArrow: "Standard" }));
myDiagram.groupTemplate =
$(go.Group,
{
avoidableMargin: new go.Margin(-30, 0, 0, 0),
layout: $(go.TreeLayout,
{
alignment: go.TreeLayout.AlignmentStart,
layerSpacing: 20,
layerSpacingParentOverlap: 1,
nodeIndentPastParent: 1.0,
nodeIndent: 10,
nodeSpacing: 10,
portSpot: new go.Spot(0.0001, 1, 5, 0)
})
},
$(go.Placeholder));
myDiagram.model = new go.GraphLinksModel(
{
nodeDataArray: [
{ key: 1, text: "Alpha", color: "coral", group: -1 },
{ key: -1, text: "Alpha Group", isGroup: true },
{ key: 101, text: "Alpha 1", group: -1 },
{ key: 102, text: "Alpha 2", group: -1 },
{ key: 2, text: "Beta", color: "tomato", group: -2 },
{ key: -2, text: "Beta Group", isGroup: true },
{ key: 201, text: "Beta 1", group: -2 },
{ key: 202, text: "Beta 2", group: -2 },
{ key: 3, text: "Gamma", color: "goldenrod" },
{ key: 4, text: "Delta", color: "orange" },
{ key: 5, text: "Epsilon", color: "coral" },
{ key: 6, text: "Zeta", color: "tomato" },
{ key: 7, text: "Eta", color: "goldenrod", group: -7 },
{ key: -7, text: "Eta Group", isGroup: true },
{ key: 701, text: "Eta 1", group: -7 },
{ key: 702, text: "Eta 2", group: -7 },
{ key: 703, text: "Eta 3", group: -7 },
{ key: 704, text: "Eta 4", group: -7 },
{ key: 8, text: "Theta", color: "orange" },
{ key: 9, text: "Iota", color: "coral" },
{ key: 10, text: "Kappa", color: "tomato" },
{ key: 11, text: "Lambda", color: "goldenrod" },
{ key: 12, text: "Mu", color: "orange" },
{ key: 13, text: "Nu", color: "coral" },
{ key: 14, text: "Xi", color: "tomato" },
{ key: 15, text: "Omicron", color: "goldenrod" },
{ key: 16, text: "Pi", color: "orange" },
{ key: 17, text: "Rho", color: "coral" },
{ key: 18, text: "Sigma", color: "tomato" },
{ key: 19, text: "Tau", color: "goldenrod" },
{ key: 20, text: "Upsilon", color: "orange" },
{ key: 21, text: "Phi", color: "coral" },
{ key: 22, text: "Chi", color: "tomato" },
{ key: 23, text: "Psi", color: "goldenrod" },
{ key: 24, text: "Omega", color: "orange", group: -24 },
{ key: -24, text: "Omega Group", isGroup: true },
{ key: 2401, text: "Omega 1", group: -24 },
{ key: 2402, text: "Omega 2", group: -24 },
],
linkDataArray: [
{ from: 1, to: 2 },
{ from: 2, to: 3 },
{ from: 3, to: 4 },
{ from: 4, to: 5 },
{ from: 5, to: 6 },
{ from: 6, to: 7 },
{ from: 7, to: 8 },
{ from: 8, to: 9 },
{ from: 9, to: 10 },
{ from: 10, to: 11 },
{ from: 11, to: 12 },
{ from: 12, to: 13 },
{ from: 13, to: 14 },
{ from: 14, to: 15 },
{ from: 15, to: 16 },
{ from: 16, to: 17 },
{ from: 17, to: 18 },
{ from: 18, to: 19 },
{ from: 19, to: 20 },
{ from: 20, to: 21 },
{ from: 21, to: 22 },
{ from: 22, to: 23 },
{ from: 23, to: 24 },
{ from: 1, to: 101 },
{ from: 1, to: 102 },
{ from: 2, to: 201 },
{ from: 2, to: 202 },
{ from: 7, to: 701 },
{ from: 701, to: 702 },
{ from: 701, to: 703 },
{ from: 7, to: 704 },
{ from: 24, to: 2401 },
{ from: 24, to: 2402 },
]
});
myDiagram.layout.root = myDiagram.findNodeForKey(1);
</script>
</body></html>
And the updated layout. I’m not sure yet how much of this will go into the published extension.
"use strict";
/*
* Copyright (C) 1998-2022 by Northwoods Software Corporation. All Rights Reserved.
*/
// A custom Layout that lays out a chain of nodes in a snake-like fashion
/*
* This is an extension and not part of the main GoJS library.
* Note that the API for this class may change with any version, even point releases.
* If you intend to use an extension in production, you should copy the code to your own source directory.
* Extensions can be found in the GoJS kit under the extensions or extensionsTS folders.
* See the Extensions intro page (https://gojs.net/latest/intro/extensions.html) for more information.
*/
/**
* @constructor
* @extends Layout
* @class
* This layout assumes the graph is a chain of Nodes,
* positioning nodes in horizontal rows back and forth, alternating between left-to-right
* and right-to-left within the {@link #wrap} limit.
* {@link #spacing} controls the distance between nodes.
* <p/>
* When this layout is the Diagram.layout, it is automatically invalidated when the viewport changes size.
*/
function SerpentineLayout() {
go.Layout.call(this);
this.isViewportSized = true;
this._spacing = new go.Size(30, 30);
this._wrap = NaN;
this.root = null;
}
go.Diagram.inherit(SerpentineLayout, go.Layout);
/**
* @ignore
* Copies properties to a cloned Layout.
* @this {SerpentineLayout}
* @param {Layout} copy
*/
SerpentineLayout.prototype.cloneProtected = function(copy) {
go.Layout.prototype.cloneProtected.call(this, copy);
copy._spacing = this._spacing;
copy._wrap = this._wrap;
};
/**
* This method actually positions all of the Nodes, assuming that the ordering of the nodes
* is given by a single link from one node to the next.
* This respects the {@link #spacing} and {@link #wrap} properties to affect the layout.
* @this {SerpentineLayout}
* @param {Diagram|Group|Iterable} coll the collection of Parts to layout.
*/
SerpentineLayout.prototype.doLayout = function(coll) {
var diagram = this.diagram;
coll = this.collectParts(coll);
var root = this.root;
if (root === null) {
// find a root node -- one without any incoming links
var it = coll.iterator;
while (it.next()) {
var n = it.value;
if (!(n instanceof go.Node)) continue;
if (root === null) root = n;
if (n.findLinksInto().count === 0) {
root = n;
break;
}
}
}
// couldn't find a root node
if (root === null) return;
var spacing = this.spacing;
// calculate the width at which we should start a new row
var wrap = this.wrap;
if (diagram !== null && isNaN(wrap)) {
if (this.group === null) { // for a top-level layout, use the Diagram.viewportBounds
var pad = diagram.padding;
wrap = Math.max(spacing.width * 2, diagram.viewportBounds.width - 24 - pad.left - pad.right);
} else {
wrap = 1000; // provide a better default value?
}
}
// implementations of doLayout that do not make use of a LayoutNetwork
// need to perform their own transactions
if (diagram !== null) diagram.startTransaction("Serpentine Layout");
// start on the left, at Layout.arrangementOrigin
this.arrangementOrigin = this.initialOrigin(this.arrangementOrigin);
var x = this.arrangementOrigin.x;
var rowh = 0;
var y = this.arrangementOrigin.y;
var increasing = true;
var node = root;
while (node !== null) {
var orignode = node;
if (node.containingGroup !== null) node = node.containingGroup;
var b = this.getLayoutBounds(node);
// get the next node, if any
var nextlink = orignode.findLinksOutOf().first();
if (nextlink !== null && nextlink.isTreeLink) nextlink = null; // don't follow links into its own subtree
var nextnode = (nextlink !== null ? nextlink.toNode : null);
var orignextnode = nextnode;
if (nextnode !== null && nextnode.containingGroup !== null) nextnode = nextnode.containingGroup;
var nb = (nextnode !== null ? this.getLayoutBounds(nextnode) : new go.Rect());
if (increasing) {
node.move(new go.Point(x, y));
x += b.width;
rowh = Math.max(rowh, b.height);
if (x + spacing.width + nb.width > wrap) {
y += rowh + spacing.height;
x = wrap - spacing.width;
rowh = 0;
increasing = false;
if (nextlink !== null) {
nextlink.fromSpot = go.Spot.Right;
nextlink.toSpot = go.Spot.Right;
}
} else {
x += spacing.width;
if (nextlink !== null) {
nextlink.fromSpot = go.Spot.Right;
nextlink.toSpot = go.Spot.Left;
}
}
} else {
x -= b.width;
node.move(new go.Point(x, y));
rowh = Math.max(rowh, b.height);
if (x - spacing.width - nb.width < 0) {
y += rowh + spacing.height;
x = 0;
rowh = 0;
increasing = true;
if (nextlink !== null) {
nextlink.fromSpot = go.Spot.Left;
nextlink.toSpot = go.Spot.Left;
}
} else {
x -= spacing.width;
if (nextlink !== null) {
nextlink.fromSpot = go.Spot.Left;
nextlink.toSpot = go.Spot.Right;
}
}
}
node = orignextnode;
}
if (diagram !== null) diagram.commitTransaction("Serpentine Layout");
};
// Public properties
/**
* Gets or sets the {@link Size} whose width specifies the horizontal space between nodes
* and whose height specifies the minimum vertical space between nodes.
* The default value is 30x30.
* @name SerpentineLayout#spacing
* @return {Size}
*/
Object.defineProperty(SerpentineLayout.prototype, "spacing", {
get: function() { return this._spacing; },
set: function(val) {
if (!(val instanceof go.Size)) throw new Error("new value for SerpentineLayout.spacing must be a Size, not: " + val);
if (!this._spacing.equals(val)) {
this._spacing = val;
this.invalidateLayout();
}
}
});
/**
* Gets or sets the total width of the layout.
* The default value is NaN, which for {@link Diagram#layout}s means that it uses
* the {@link Diagram#viewportBounds}.
* @name SerpentineLayout#wrap
* @return {number}
*/
Object.defineProperty(SerpentineLayout.prototype, "wrap", {
get: function() { return this._wrap; },
set: function(val) {
if (this._wrap !== val) {
this._wrap = val;
this.invalidateLayout();
}
}
});