/*
* Copyright 1998-2026 by Northwoods Software Corporation. All Rights Reserved.
*/
/*
* This is an extension and not part of the main GoJS library.
* The source code for this is at extensionsJSM/TreeMapLayout.ts.
* 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 extensionsJSM folders.
* See the Extensions intro page (https://gojs.net/latest/intro/extensions.html) for more information.
*/
import * as go from 'gojs';
/**
* This enumeration is used to determine the algorithm for placing nodes in {@link TreeMapLayout}.
*
* Note: this enumeration is only exists in extensionsJSM, not in extensions.
* @since 3.0
* @category Layout Extension
*/
export enum TreeMapPlacement {
/**
* Places nodes to maximize the aspect ratio of each node being close to the chosen aspect ratio.
* This placement does not maintain node order.
*
* The {@link TreeMapLayout.aspectRatio} property determines what aspect ratio to prioritize.
* The starting orientation is determined by {@link TreeMapLayout.isTopLevelHorizontal}.
* Layer orientation is determined by {@link TreeMapLayout.alternatingOrientation}.
*/
AspectRatio = 0,
/**
* Places nodes by equally splitting space for each node on one axis. Alternates what axis nodes are placed on at each level.
* Maintains placement of nodes near each other.
*
* The starting orientation is determined by {@link TreeMapLayout.isTopLevelHorizontal}.
*/
SliceAndDice = 1,
/**
* Places nodes maintaining node order by placing remaining nodes around a chosen pivot.
* Pivot can be selected by {@link TreeMapLayout.treeMapOrderedPivot}.
* Choses orientation based on available width and height.
* Sections under 5 nodes will be placed according to {@link TreeMapLayout.treeMapOrderedStoppingLayout}
*/
Ordered = 2,
}
/**
* This enumeration is used to determine the pivot node in {@link TreeMapPlacement.Ordered} layouts.
*
* Note: this enumeration is only exists in extensionsJSM, not in extensions.
* @since 3.0
* @category Layout Extension
*/
export enum TreeMapOrderedPivot {
/**
* Selects the largest node as the pivot node.
*/
Size = 0,
/**
* Selects a pivot node which best splits the remaining nodes in half by total size.
*/
SplitSize = 1,
/**
* Chooses the middle node as the pivot node.
*/
Middle = 2,
}
/**
* This enumeration is used to determine the stopping layout in {@link TreeMapPlacement.Ordered} layouts.
* This layout is used once a section has 4 or less nodes.
*
* Note: this enumeration is only exists in extensionsJSM, not in extensions.
* @since 3.0
* @category Layout Extension
*/
export enum TreeMapOrderedStoppingLayout {
/**
* Makes a conical spiral of the remaining nodes.
*/
Conical = 0,
/**
* Places remaining nodes in a line along the longer remaining axis.
*/
Line = 1,
}
/**
* A custom {@link go.Layout} that lays out hierarchical data using nested rectangles.
*
* If you want to experiment with this extension, try the TreeMap Layout sample.
* @category Layout Extension
*/
export class TreeMapLayout extends go.Layout {
private _isTopLevelHorizontal: boolean;
private _alternatingOrientation: boolean;
private _equalSpacing: boolean;
private _aspectRatio: number;
private _layerSpacing: number;
private _size: go.Size;
private _treeMapPlacement: TreeMapPlacement;
private _treeMapOrderedPivot: TreeMapOrderedPivot;
private _treeMapOrderedStoppingLayout: TreeMapOrderedStoppingLayout;
constructor(init?: Partial) {
super();
this._isTopLevelHorizontal = true;
this._alternatingOrientation = false;
this._equalSpacing = false;
this._aspectRatio = 2.5;
this._layerSpacing = 10;
this._size = new go.Size(NaN, NaN);
this._treeMapPlacement = TreeMapPlacement.AspectRatio;
this._treeMapOrderedPivot = TreeMapOrderedPivot.Size;
this._treeMapOrderedStoppingLayout = TreeMapOrderedStoppingLayout.Conical;
if (init) Object.assign(this, init);
}
/**
* Gets or sets whether top level parts are laid out horizontally.
*
* Default is true.
*/
get isTopLevelHorizontal(): boolean {
return this._isTopLevelHorizontal;
}
set isTopLevelHorizontal(val: boolean) {
val = !!val;
if (this._isTopLevelHorizontal !== val) {
this._isTopLevelHorizontal = val;
this.invalidateLayout();
}
}
/**
* Gets or sets whether each layers orientation is determined by its parents.
* This only applies if {@link TreeMapPlacement} is {@link TreeMapPlacement.AspectRatio}
* If true each layer will alternate, if not the orientation will be determined by whether width
* or height is larger.
*
* Default is false.
*/
get alternatingOrientation(): boolean {
return this._alternatingOrientation;
}
set alternatingOrientation(val: boolean) {
val = !!val;
if (this._alternatingOrientation !== val) {
this._alternatingOrientation = val;
this.invalidateLayout();
}
}
/**
* Gets or sets whether each layers spacing is multiplied by the layer its on.
*
* Default is false.
*/
get equalSpacing(): boolean {
return this._equalSpacing;
}
set equalSpacing(val: boolean) {
val = !!val;
if (this._equalSpacing !== val) {
this._equalSpacing = val;
this.invalidateLayout();
}
}
/**
* Gets or sets the prioritized aspect ratio for nodes.
*
* This only applies if {@link TreeMapPlacement} is {@link TreeMapPlacement.AspectRatio}.
* Default is 2.5.
*/
get aspectRatio(): number {
return this._aspectRatio;
}
set aspectRatio(value: number) {
if (this.aspectRatio !== value && this.isNumeric(value) && value > 0) {
this._aspectRatio = value;
this.invalidateLayout();
}
}
/**
* Gets or sets the spacing factor for each layer
*
* Default is 10.
*/
get layerSpacing(): number {
return this._layerSpacing;
}
set layerSpacing(value: number) {
if (this.layerSpacing !== value && this.isNumeric(value) && value > 0) {
this._layerSpacing = value;
this.invalidateLayout();
}
}
/**
* Gets or sets the size for the layout to fill. Values of NaN fill the viewport in
* the given direction.
*
* The default value is NaN x NaN, which fills the full viewport.
*/
get size(): go.Size {
return this._size;
}
set size(value: go.Size) {
// check if both width and height are NaN, as per https://stackoverflow.com/a/16988441
if (
((this.isNumeric(value.width) && value.width >= 0) || value.width !== value.width) &&
((this.isNumeric(value.height) && value.height >= 0) || value.height !== value.height)
) {
this._size = value;
this.invalidateLayout();
}
}
/**
* Gets or sets the method by which nodes will be placed.
* Valid values are {@link TreeMapPlacement} values.
*
* The default value is {@link TreeMapPlacement.AspectRatio}.
*/
get treeMapPlacement(): TreeMapPlacement {
return this._treeMapPlacement;
}
set treeMapPlacement(value: TreeMapPlacement) {
if (this.treeMapPlacement !== value &&
(value === TreeMapPlacement.SliceAndDice || value === TreeMapPlacement.Ordered || value === TreeMapPlacement.AspectRatio)) {
this._treeMapPlacement = value;
this.invalidateLayout();
}
}
/**
* Gets or sets the method by which the pivot node is chosen.
* Valid values are {@link TreeMapOrderedPivot} values.
*
* The default value is {@link TreeMapOrderedPivot.Size}.
*/
get treeMapOrderedPivot(): TreeMapOrderedPivot {
return this._treeMapOrderedPivot;
}
set treeMapOrderedPivot(value: TreeMapOrderedPivot) {
if (this.treeMapOrderedPivot !== value &&
(value === TreeMapOrderedPivot.Size || value === TreeMapOrderedPivot.Middle || value === TreeMapOrderedPivot.SplitSize)) {
this._treeMapOrderedPivot = value;
this.invalidateLayout();
}
}
/**
* Gets or sets the method by which nodes will be placed when there are less than 4 in
* an Ordered placement.
* Valid values are {@link TreeMapOrderedStoppingLayout} values.
*
* The default value is {@link TreeMapOrderedStoppingLayout.Conical}.
*/
get treeMapOrderedStoppingLayout(): TreeMapOrderedStoppingLayout {
return this._treeMapOrderedStoppingLayout;
}
set treeMapOrderedStoppingLayout(value: TreeMapOrderedStoppingLayout) {
if (this.treeMapOrderedStoppingLayout !== value &&
(value === TreeMapOrderedStoppingLayout.Conical || value === TreeMapOrderedStoppingLayout.Line )) {
this._treeMapOrderedStoppingLayout= value;
this.invalidateLayout();
}
}
/**
* Copies properties to a cloned Layout.
*/
override cloneProtected(copy: this): void {
super.cloneProtected(copy);
copy._isTopLevelHorizontal = this._isTopLevelHorizontal;
copy._alternatingOrientation = this._alternatingOrientation;
copy._aspectRatio = this._aspectRatio;
copy._treeMapPlacement = this._treeMapPlacement;
copy._treeMapOrderedStoppingLayout = this._treeMapOrderedStoppingLayout;
copy._treeMapOrderedPivot = this._treeMapOrderedPivot;
copy._size = this._size;
copy._equalSpacing = this._equalSpacing;
copy._layerSpacing = this._layerSpacing;
}
/**
* This method actually positions all of the nodes by determining total area and then recursively tiling nodes from the top-level down.
* @param coll - A {@link go.Diagram} or a {@link go.Group} or a collection of {@link go.Part}s.
*/
override doLayout(coll: go.Diagram | go.Group | go.Iterable): void {
if (!(coll instanceof go.Diagram)) throw new Error('TreeMapLayout only works as the Diagram.layout');
const diagram = coll;
this.computeTotals(diagram); // make sure data.total has been computed for every node
// figure out how large an area to cover;
this.arrangementOrigin = this.initialOrigin(this.arrangementOrigin);
const x = this.arrangementOrigin.x;
const y = this.arrangementOrigin.y;
// checks whether to use given sizes or to use the viewport determined by size being NaN
let w = 0;
let h = 0;
if (isNaN(this.size.width)) {
w = diagram.viewportBounds.width;
if (isNaN(w)) w = 1000;
} else {
w = this.size.width;
}
if (isNaN(this.size.height)) {
h = diagram.viewportBounds.height;
if (isNaN(h)) h = 1000;
} else {
h = this.size.height;
}
if (h === 0 || w === 0) { // If size is set to 0 all of the nodes will be 0 sized
diagram.nodes.each((n) => {
n.desiredSize = new go.Size(0, 0);
})
return;
}
// collect all top-level nodes, and sum their totals
const tops: Array = [];
let total = 0;
diagram.nodes.each((n) => {
if (n.isTopLevel) {
tops.push(n);
total += n.data.total;
}
});
// kicks out if there are no nodes
if (tops.length < 1) return;
// picks the chosen layout based on treeMapPlacement
if (this._treeMapPlacement === TreeMapPlacement.SliceAndDice) {
this.layoutSliceAndDice(tops, total, this.isTopLevelHorizontal, x, y, w, h, 1);
} else if (this._treeMapPlacement === TreeMapPlacement.AspectRatio) {
// sorts the nodes size for aspectRatio layout
tops.sort((a, b) => b.data.total - a.data.total);
this.layoutAspectRatio(tops, total, this.isTopLevelHorizontal, x, y, w, h, 1);
} else if (this._treeMapPlacement === TreeMapPlacement.Ordered) {
this.layoutOrdered(tops, total, x, y, w, h, 1);
}
}
/**
* @hidden @internal
*/
layoutAspectRatio(
nodes: Array,
total: number,
horiz: boolean,
x: number,
y: number,
w: number,
h: number,
l: number
): void {
const aspectRatio = this.aspectRatio;
const convertFactor = Math.sqrt((w * h) / total);
// conversion factor between "total" value and size of layout
let placeableNodes: Array = [];
let placeableTotal: number = 0;
let placeableFactor: number = Number.MAX_SAFE_INTEGER;
// check whether or place vertically or horizontally
// important note placing "horizontally" will cause nodes to be place vertically on the edge as they are horizontally placed with the rest of the nodes
if (this.alternatingOrientation? horiz : (w > h)) {
let placeableWidth: number = 0;
// goes through each node to add them to the array
while (nodes.length > 0) {
let node: go.Part = nodes[0];
let newTotal = placeableTotal + node.data.total;
let nodesWidth = newTotal / (h / convertFactor);
let nodesRatio = nodesWidth * nodesWidth / node.data.total;
// gets the next largest node and checks if adding it will get the aspect ratio closer or farther
if (Math.abs(aspectRatio - nodesRatio) < placeableFactor) {
placeableFactor = Math.abs(aspectRatio - nodesRatio);
placeableTotal = newTotal;
placeableWidth = nodesWidth;
placeableNodes.push(nodes.shift()!);
} else {
// once the closest to the ratio has been achieved it breaks and recursive calls to place the rest
break;
}
}
let py = y;
// places all the selected nodes into a stack on the left
placeableNodes.forEach((node: go.Part) => {
this.layoutNode(horiz, node, x, py, placeableWidth * convertFactor, (node.data.total / placeableWidth) * convertFactor, l);
py += (node.data.total / placeableWidth) * convertFactor;
});
// if any nodes are left they are placed into the remaining area
if (nodes.length > 0) {
this.layoutAspectRatio(nodes, total - placeableTotal, !horiz, x + (placeableWidth * convertFactor), y, w - (placeableWidth * convertFactor), h, l);
}
} else {
let placeableHeight: number = 0;
// goes through each node to add them to the array
while (nodes.length > 0) {
let node: go.Part = nodes[0];
let newTotal = placeableTotal + node.data.total;
let nodesHeight = newTotal / (w / convertFactor);
let nodesRatio = (node.data.total / nodesHeight) / nodesHeight;
// gets the next largest node and checks if adding it will get the aspect ratio closer or farther
if (Math.abs(aspectRatio - nodesRatio) < placeableFactor) {
placeableFactor = Math.abs(aspectRatio - nodesRatio);
placeableTotal = newTotal;
placeableHeight = nodesHeight;
placeableNodes.push(nodes.shift()!);
} else {
break;
}
}
let px = x;
// places the selected nodes in a row at the top
placeableNodes.forEach((node: go.Part) => {
this.layoutNode(horiz, node, px, y, (node.data.total / placeableHeight) * convertFactor, placeableHeight * convertFactor, l);
px += (node.data.total / placeableHeight) * convertFactor;
});
// if any nodes are left they are placed into the remaining area
if (nodes.length > 0) {
this.layoutAspectRatio(nodes, total - placeableTotal, !horiz, x, y + (placeableHeight * convertFactor), w, h - (placeableHeight * convertFactor), l);
}
}
}
/**
* @hidden @internal
*/
layoutOrdered(
nodes: Array,
total: number,
x: number,
y: number,
w: number,
h: number,
l: number
): void {
// if there are less than 5 nodes they are placed into the set end placement
if (nodes.length < 5) {
if (this.treeMapOrderedStoppingLayout === TreeMapOrderedStoppingLayout.Conical) {
this.layoutConical(nodes, total, x, y, w, h, l);
} else if (this.treeMapOrderedStoppingLayout === TreeMapOrderedStoppingLayout.Line) {
this.layoutLine(nodes, total, x, y, w, h, l);
}
} else {
let pivotNode: go.Part;
// based on selected method the pivot node is calculated
if (this.treeMapOrderedPivot === TreeMapOrderedPivot.Middle) {
// finds the node in the middle
pivotNode = nodes[Math.floor(nodes.length / 2)]
} else if (this.treeMapOrderedPivot === TreeMapOrderedPivot.Size) {
// finds the node with the largest total
let largestSize = 0;
nodes.forEach(node => {
if (node.data.total > largestSize) {
pivotNode = node;
largestSize = node.data.total;
}
});
} else if (this.treeMapOrderedPivot === TreeMapOrderedPivot.SplitSize) {
// starts totalling from the left and right to find the node which best splits the list in half
let li = 0;
let ri = nodes.length - 1;
let lt = nodes[li].data.total;
let rt = nodes[ri].data.total;
while (ri - li > 2) {
if (lt < rt) {
li++;
lt += nodes[li].data.total;
} else {
ri--;
rt += nodes[ri].data.total;
}
}
pivotNode = nodes[li + 1];
}
// gets the index of the pivot node
pivotNode = pivotNode!;
let pivotIndex = nodes.findIndex((x) => {
return (x === pivotNode);
});
// divides up remaining nodes into L1 (before pivot) and L3 (after pivot)
let L1: Array = nodes.slice(0, pivotIndex);
let L2: Array = [];
let L3: Array = nodes.slice(pivotIndex + 1);
// calculates total value of L2 which would allow pivot node to be square
let targetTotal = (((Math.sqrt((pivotNode.data.total / total) * (w * h))) * h) / (w * h)) * total;
let currentTotal = pivotNode.data.total;
// checks and attempts to add nodes from the start of L3 to L2 to get as close to calculated total as possible
while (L3.length > 0) {
const node = L3[0];
if (Math.abs(targetTotal - currentTotal) > Math.abs(targetTotal - (currentTotal + node.data.total))) {
L2.push(L3.shift()!);
currentTotal += node.data.total;
} else {
break;
}
}
// calculate total for each list of nodes
let L1Total = L1.reduce((total, node) => total + parseInt(node.data.total), 0);
let L2Total = currentTotal - pivotNode.data.total;
let L3Total = L3.reduce((total, node) => total + parseInt(node.data.total), 0);
if (w > h) {
let px = x;
// if there are nodes in L1 there are placed on the left
if (L1.length > 0) {
this.layoutOrdered(L1, L1Total, px, y, w * (L1Total / total), h, l);
}
px += w * (L1Total / total);
// pivot node is placed at the top of the middle section
this.layoutNode(true, pivotNode, px, y, w * (currentTotal / total), h * (pivotNode.data.total / currentTotal), l);
// L2 fills up rest of middle section
if (L2.length > 0) {
this.layoutOrdered(L2, L2Total, px, y + h * (pivotNode.data.total / currentTotal), w * (currentTotal / total), h * (L2Total / currentTotal), l);
}
px += w * (currentTotal / total);
// L3 takes of rest of the space on the right
if (L3.length > 0) {
this.layoutOrdered(L3, L3Total, px, y, w * (L3Total / total), h, l);
}
} else {
let py = y;
// nodes in L1 are placed at the top
if (L1.length > 0) {
this.layoutOrdered(L1, L1Total, x, py, w, h * (L1Total / total), l);
}
py += h * (L1Total / total);
// pivot node is placed on the left of the middle row
this.layoutNode(true, pivotNode, x, py, w * (pivotNode.data.total / currentTotal), h * (currentTotal / total), l);
if (L2.length > 0) {
this.layoutOrdered(L2, L2Total, x + w * (pivotNode.data.total / currentTotal), py, w * (L2Total / currentTotal), h * (currentTotal / total), l);
}
py += h * (currentTotal / total);
// L3 fills up rest of space at the bottom
if (L3.length > 0) {
this.layoutOrdered(L3, L3Total, x, py, w, h * (L3Total / total), l);
}
}
}
}
/**
* @hidden @internal
*/
layoutSliceAndDice(
nodes: Array,
total: number,
horiz: boolean,
x: number,
y: number,
w: number,
h: number,
l: number
): void {
let gx = x;
let gy = y;
const lay = this;
// goes through and places nodes in a line giving them area based on their fraction of total of their parent
nodes.forEach((part) => {
const tot = part.data.total;
if (horiz) {
const pw = (w * tot) / total;
lay.layoutNode(!horiz, part, gx, gy, pw, h, l);
gx += pw;
} else {
const ph = (h * tot) / total;
lay.layoutNode(!horiz, part, gx, gy, w, ph, l);
gy += ph;
}
});
}
/**
* @hidden @internal
*/
layoutNode(
horiz: boolean,
part: go.Part,
x: number,
y: number,
w: number,
h: number,
l: number
): void {
const spacing = (this.equalSpacing)? this.layerSpacing : this.layerSpacing / (l * 2);
// places node on diagram and sets nodes size
part.moveTo(x + spacing, y + spacing);
part.desiredSize = new go.Size(Math.max(w - spacing * 2, 1), Math.max(h - spacing * 2, 1));
// if part is a group and has children they are placed
if (part instanceof go.Group) {
const g = part;
// gets total and collects list of children
const total = g.data.total;
const children: Array = [];
g.memberParts.each((p) => {
children.push(p);
});
// if there aren't are children returns
if (children.length < 1) return;
// places them based on selected placement
if (this._treeMapPlacement === TreeMapPlacement.SliceAndDice) {
this.layoutSliceAndDice(children, total, horiz, x + spacing, y + spacing, Math.max(w - spacing * 2, 1), Math.max(h - spacing * 2, 1), l + 1);
} else if (this._treeMapPlacement === TreeMapPlacement.AspectRatio) {
// sorts children by size for aspect ratio placement
children.sort((a, b) => b.data.total - a.data.total);
this.layoutAspectRatio(children, total, horiz, x + spacing, y + spacing, Math.max(w - spacing * 2, 1), Math.max(h - spacing * 2, 1), l + 1);
} else if (this._treeMapPlacement === TreeMapPlacement.Ordered) {
this.layoutOrdered(children, total, x + spacing, y + spacing, Math.max(w - spacing * 2, 1), Math.max(h - spacing * 2, 1), l + 1);
}
}
}
/**
* @hidden @internal
*/
layoutConical(
nodes: Array,
total: number,
x: number,
y: number,
w: number,
h: number,
l: number
): void {
let horiz = true;
// goes through letting every node take up the full length of the axis
nodes.forEach((node: go.Part) => {
if (horiz) {
this.layoutNode(true, node, x, y, w * (node.data.total / total), h, l);
x += w * (node.data.total / total);
w -= w * (node.data.total / total);
total -= node.data.total;
} else {
this.layoutNode(true, node, x, y, w, h * (node.data.total / total), l);
y += h * (node.data.total / total);
h -= h * (node.data.total / total);
total -= node.data.total;
}
horiz = !horiz;
});
}
/**
* @hidden @internal
*/
layoutLine(
nodes: Array,
total: number,
x: number,
y: number,
w: number,
h: number,
l: number
): void {
let horiz = w > h;
// splits remaining space into a row or column of remaining nodes
nodes.forEach((node: go.Part) => {
if (horiz) {
this.layoutNode(true, node, x, y, w * (node.data.total / total), h, l);
x += w * (node.data.total / total);
} else {
this.layoutNode(true, node, x, y, w, h * (node.data.total / total), l);
y += h * (node.data.total / total);
}
});
}
/**
* Compute the `data.total` for each node in the Diagram, with a {@link go.Group}'s being a sum of its members.
*/
computeTotals(diagram: go.Diagram): void {
if (!diagram.nodes.all((g: go.Node) => !(g instanceof go.Group) || g.data.total >= 0)) {
let groups = new Set();
diagram.nodes.each((n: go.Node) => {
if (n instanceof go.Group) {
// collect all groups
groups.add(n);
} else {
// regular nodes just have their total == size
n.data.total = n.data.size;
}
});
// keep looking for groups whose total can be computed, until all groups have been processed
while (groups.size > 0) {
const grps = new Set();
groups.forEach((g: go.Group) => {
// for a group all of whose member nodes have an initialized data.total,
if (g.memberParts.all((m) => !(m instanceof go.Group) || m.data.total >= 0)) {
// compute the group's total as the sum of the sizes of all of the member nodes
g.data.total = 0;
g.memberParts.each((m) => {
if (m instanceof go.Node) g.data.total += m.data.total;
});
} else {
// remember for the next iteration
grps.add(g);
}
});
groups = grps;
}
}
}
/**
* @hidden @internal
* Checks if a value is a number, used for parameter validation
* @param value - the value to check
*/
private isNumeric(value: number): boolean {
return typeof value === 'number' && !isNaN(value) && isFinite(value);
}
}