gojs
Version:
Interactive diagrams, charts, and graphs, such as trees, flowcharts, orgcharts, UML, BPMN, or business diagrams
1,039 lines • 84.4 kB
JavaScript
/*
* Copyright (C) 1998-2020 by Northwoods Software Corporation. All Rights Reserved.
*/
/*
* 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.
*/
import * as go from '../release/go-module.js';
import { Quadtree } from './Quadtree.js';
/**
* @hidden @internal
* Used to represent the perimeter of the currently packed
* shape when packing rectangles. Segments are always assumed
* to be either horizontal or vertical, and store whether or
* not their first point is concave (this makes sense in the
* context of representing a perimeter, as the next segment
* will always be connected to the last).
*/
class Segment {
/**
* @hidden @internal
* Constructs a new Segment. Segments are assumed to be either
* horizontal or vertical, and the given coordinates should
* reflect that.
* @param x1 the x coordinate of the first point
* @param y1 the y coordinate of the first point
* @param x2 the x coordinate of the second point
* @param y2 the y coordinate of the second point
* @param p1Concave whether or not the first point is concave
*/
constructor(x1, y1, x2, y2, p1Concave) {
this.x1 = x1;
this.y1 = y1;
this.x2 = x2;
this.y2 = y2;
this.p1Concave = p1Concave;
this.isHorizontal = Math.abs(y2 - y1) < 1e-7;
}
}
/**
* @hidden @internal
* Defines the possible orientations that two adjacent
* horizontal/vertical segments can form.
*/
var Orientation;
(function (Orientation) {
Orientation[Orientation["NE"] = 0] = "NE";
Orientation[Orientation["NW"] = 1] = "NW";
Orientation[Orientation["SW"] = 2] = "SW";
Orientation[Orientation["SE"] = 3] = "SE";
})(Orientation || (Orientation = {}));
/**
* @hidden @internal
* Structure for storing possible placements when packing
* rectangles. Fits have a cost associated with them (lower
* cost placements are preferred), and can be placed relative
* to either one or two segments. If the fit is only placed
* relative to one segment, s2 will be undefined. Fits placed
* relative to multiple segments will hereafter be referred to
* as "skip fits".
*/
class Fit {
/**
* @hidden @internal
* Constructs a new Fit.
* @param bounds the boundaries of the placement, including defined x and y coordinates
* @param cost the cost of the placement, lower cost fits will be preferred
* @param s1 the segment that the placement was made relative to
* @param s2 the second segment that the placement was made relative to, if the fit is a skip fit
*/
constructor(bounds, cost, s1, s2) {
this.bounds = bounds;
this.cost = cost;
this.s1 = s1;
this.s2 = s2;
}
}
/**
* Custom layout which attempts to pack nodes as close together as possible
* without overlap. Each node is assumed to be either rectangular or
* circular (dictated by the {@link #hasCircularNodes} property). This layout
* supports packing nodes into either a rectangle or an ellipse, with the
* shape determined by the packShape property and the aspect ratio determined
* by either the aspectRatio property or the specified width and height
* (depending on the packMode).
*
* Nodes with 0 width or height cannot be packed, so they are treated by this
* layout as having a width or height of 0.1 instead.
* @category Layout Extension
*/
export class PackedLayout extends go.Layout {
constructor() {
super(...arguments);
// configuration defaults
/** @hidden @internal */ this._packShape = PackedLayout.Elliptical;
/** @hidden @internal */ this._packMode = PackedLayout.AspectOnly;
/** @hidden @internal */ this._sortMode = PackedLayout.None;
/** @hidden @internal */ this._sortOrder = PackedLayout.Descending;
/** @hidden @internal */ this._comparer = undefined;
/** @hidden @internal */ this._aspectRatio = 1;
/** @hidden @internal */ this._size = new go.Size(500, 500);
/** @hidden @internal */ this._defaultSize = this._size.copy();
/** @hidden @internal */ this._fillViewport = false; // true if size is (NaN, NaN)
/** @hidden @internal */ this._spacing = 0;
/** @hidden @internal */ this._hasCircularNodes = false;
/** @hidden @internal */ this._arrangesToOrigin = true;
/**
* @hidden @internal
* The forced spacing value applied in the {@link PackedLayout.Fit}
* and {@link PackedLayout.ExpandToFit} modes.
*/
this._fixedSizeModeSpacing = 0;
/**
* @hidden @internal
* The actual target aspect ratio, set from either {@link #aspectRatio}
* or from the {@link #size}, depending on the {@link #packMode}.
*/
this._eAspectRatio = this._aspectRatio;
// layout state
/** @hidden @internal */ this._center = new go.Point();
/** @hidden @internal */ this._bounds = new go.Rect();
/** @hidden @internal */ this._actualBounds = new go.Rect();
/** @hidden @internal */ this._enclosingCircle = null;
/** @hidden @internal */ this._minXSegment = null;
/** @hidden @internal */ this._minYSegment = null;
/** @hidden @internal */ this._maxXSegment = null;
/** @hidden @internal */ this._maxYSegment = null;
/** @hidden @internal */ this._tree = new Quadtree();
// saved node bounds and segment list to use to calculate enclosing circle in the enclosingCircle getter
/** @hidden @internal */ this._nodeBounds = [];
/** @hidden @internal */ this._segments = new CircularDoublyLinkedList();
}
/**
* Gets or sets the shape that nodes will be packed into. Valid values are
* {@link PackedLayout.Elliptical}, {@link PackedLayout.Rectangular}, and
* {@link PackedLayout.Spiral}.
*
* In {@link PackedLayout.Spiral} mode, nodes are not packed into a particular
* shape, but rather packed consecutively one after another in a spiral fashion.
* The {@link #aspectRatio} property is ignored in this mode, and
* the {@link #size} property (if provided) is expected to be square.
* If it is not square, the largest dimension given will be used. This mode
* currently only works with circular nodes, so setting it cause the assume that
* layout to assume that {@link #hasCircularNodes} is true.
*
* Note that this property sets only the shape, not the aspect ratio. The aspect
* ratio of this shape is determined by either {@link #aspectRatio}
* or {@link #size}, depending on the {@link #packMode}.
*
* When the {@link #packMode} is {@link PackedLayout.Fit} or
* {@link PackedLayout.ExpandToFit} and this property is set to true, the
* layout will attempt to make the diameter of the enclosing circle of the
* layout approximately equal to the greater dimension of the given
* {@link #size} property.
*
* The default value is {@link PackedLayout.Elliptical}.
*/
get packShape() { return this._packShape; }
set packShape(value) {
if (this._packShape !== value && (value === PackedLayout.Elliptical || value === PackedLayout.Rectangular || value === PackedLayout.Spiral)) {
this._packShape = value;
this.invalidateLayout();
}
}
/**
* Gets or sets the mode that the layout will use to determine its size. Valid values
* are {@link PackedLayout.AspectOnly}, {@link PackedLayout.Fit}, and {@link PackedLayout.ExpandToFit}.
*
* The default value is {@link PackedLayout.AspectOnly}. In this mode, the layout will simply
* grow as needed, attempting to keep the aspect ratio defined by {@link #aspectRatio}.
*/
get packMode() { return this._packMode; }
set packMode(value) {
if (value === PackedLayout.AspectOnly || value === PackedLayout.Fit || value === PackedLayout.ExpandToFit) {
this._packMode = value;
this.invalidateLayout();
}
}
/**
* Gets or sets the method by which nodes will be sorted before being packed. To change
* the order, see {@link #sortOrder}.
*
* The default value is {@link PackedLayout.None}, in which nodes will not be sorted at all.
*/
get sortMode() { return this._sortMode; }
set sortMode(value) {
if (value === PackedLayout.None || value === PackedLayout.MaxSide || value === PackedLayout.Area) {
this._sortMode = value;
this.invalidateLayout();
}
}
/**
* Gets or sets the order that nodes will be sorted in before being packed. To change
* the sort method, see {@link #sortMode}.
*
* The default value is {@link PackedLayout.Descending}
*/
get sortOrder() { return this._sortOrder; }
set sortOrder(value) {
if (value === PackedLayout.Descending || value === PackedLayout.Ascending) {
this._sortOrder = value;
this.invalidateLayout();
}
}
/**
* Gets or sets the comparison function used for sorting nodes.
*
* By default, the comparison function is set according to the values of {@link #sortMode}
* and {@link #sortOrder}.
*
* Whether this comparison function is used is determined by the value of {@link #sortMode}.
* Any value except {@link PackedLayout.None} will result in the comparison function being used.
* ```js
* $(PackedLayout,
* {
* sortMode: PackedLayout.Area,
* comparer: function(na, nb) {
* var na = na.data;
* var nb = nb.data;
* if (da.someProperty < db.someProperty) return -1;
* if (da.someProperty > db.someProperty) return 1;
* return 0;
* }
* }
* )
* ```
*/
get comparer() { return this._comparer; }
set comparer(value) {
if (typeof value === 'function') {
this._comparer = value;
}
}
/**
* Gets or sets the aspect ratio for the shape that nodes will be packed into.
* The provided aspect ratio should be a nonzero postive number.
*
* Note that this only applies if the {@link #packMode} is
* {@link PackedLayout.AspectOnly}. Otherwise, the {@link #size}
* will determine the aspect ratio of the packed shape.
*
* The default value is 1.
*/
get aspectRatio() { return this._aspectRatio; }
set aspectRatio(value) {
if (this.isNumeric(value) && isFinite(value) && value > 0) {
this._aspectRatio = value;
this.invalidateLayout();
}
}
/**
* Gets or sets the size for the shape that nodes will be packed into.
* To fill the viewport, set a size with a width and height of NaN. Size
* values of 0 are considered for layout purposes to instead be 1.
*
* If the width and height are set to NaN (to fill the viewport), but this
* layout has no diagram associated with it, the default value of size will
* be used instead.
*
* Note that this only applies if the {@link #packMode} is
* {@link PackedLayout.Fit} or {@link PackedLayout.ExpandToFit}.
*
* The default value is 500x500.
*/
get size() { return this._size; }
set size(value) {
// check if both width and height are NaN, as per https://stackoverflow.com/a/16988441
if (value.width !== value.width && value.height !== value.height) {
this._size = value;
this._fillViewport = true;
this.invalidateLayout();
}
else if (this.isNumeric(value.width) && isFinite(value.width) && value.width >= 0
&& this.isNumeric(value.height) && isFinite(value.height) && value.height >= 0) {
this._size = value;
this.invalidateLayout();
}
}
/**
* Gets or sets the spacing between nodes. This value can be set to any
* real number (a negative spacing will compress nodes together, and a
* positive spacing will leave space between them).
*
* Note that the spacing value is only respected in the {@link PackedLayout.Fit}
* {@link #packMode} if it does not cause the layout to grow outside
* of the specified bounds. In the {@link PackedLayout.ExpandToFit}
* {@link #packMode}, this property does not do anything.
*
* The default value is 0.
*/
get spacing() { return this._spacing; }
set spacing(value) {
if (this.isNumeric(value) && isFinite(value)) {
this._spacing = value;
this.invalidateLayout();
}
}
/**
* Gets or sets whether or not to assume that nodes are circular. This changes
* the packing algorithm to one that is much more efficient for circular nodes.
*
* As this algorithm expects circles, it is assumed that if this property is set
* to true that the given nodes will all have the same height and width. All
* calculations are done using the width of the given nodes, so unexpected results
* may occur if the height differs from the width.
*
* The default value is false.
*/
get hasCircularNodes() { return this._hasCircularNodes; }
set hasCircularNodes(value) {
if (typeof (value) === typeof (true) && value !== this._hasCircularNodes) {
this._hasCircularNodes = value;
this.invalidateLayout();
}
}
/**
* This read-only property is the effective spacing calculated after {@link PackedLayout#doLayout}.
*
* If the {@link #packMode} is {@link PackedLayout.AspectOnly}, this will simply be the
* {@link #spacing} property. However, in the {@link PackedLayout.Fit} and
* {@link PackedLayout.ExpandToFit} modes, this property will include the forced spacing added by
* the modes themselves.
*
* Note that this property will only return a valid value after a layout has been performed. Before
* then, its behavior is undefined.
*/
get actualSpacing() { return this.spacing + this._fixedSizeModeSpacing; }
/**
* This read-only property returns the actual rectangular bounds occupied by the packed nodes.
* This property does not take into account any kind of spacing around the packed nodes.
*
* Note that this property will only return a valid value after a layout has been performed. Before
* then, its behavior is undefined.
*/
get actualBounds() { return this._actualBounds; }
/**
* This read-only property returns the smallest enclosing circle around the packed nodes. It makes
* use of the {@link #hasCircularNodes} property to determine whether or not to make
* enclosing circle calculations for rectangles or for circles. This property does not take into
* account any kind of spacing around the packed nodes. The enclosing circle calculation is
* performed the first time this property is retrieved, and then cached to prevent slow accesses
* in the future.
*
* Note that this property will only return a valid value after a layout has been performed. Before
* then, its behavior is undefined.
*
* This property is included as it may be useful for some data visualizations.
*/
get enclosingCircle() {
if (this._enclosingCircle === null) {
if (this.hasCircularNodes || this.packShape === PackedLayout.Spiral) { // remember, spiral mode assumes hasCircularNodes
const circles = new Array(this._nodeBounds.length);
for (let i = 0; i < circles.length; i++) {
const bounds = this._nodeBounds[i];
const r = bounds.width / 2;
circles[i] = new Circle(bounds.x + r, bounds.y + r, r);
}
this._enclosingCircle = enclose(circles);
}
else {
const points = new Array(); // TODO: make this work with segments, not the whole nodeboudns list
let segment = this._segments.start;
if (segment !== null) {
do {
points.push(new go.Point(segment.data.x1, segment.data.y1));
segment = segment.next;
} while (segment !== this._segments.start);
}
this._enclosingCircle = enclose(points);
}
}
return this._enclosingCircle;
}
/**
* Gets or sets whether or not to use the {@link Layout#arrangementOrigin}
* property when placing nodes.
*
* The default value is true.
*/
get arrangesToOrigin() { return this._arrangesToOrigin; }
set arrangesToOrigin(value) {
if (typeof (value) === typeof (true) && value !== this._arrangesToOrigin) {
this._arrangesToOrigin = value;
this.invalidateLayout();
}
}
/**
* Performs the PackedLayout.
* @this {PackedLayout}
* @param {Diagram|Group|Iterable.<Part>} coll A {@link Diagram} or a {@link Group} or a collection of {@link Part}s.
*/
doLayout(coll) {
const diagram = this.diagram;
if (diagram !== null)
diagram.startTransaction('Layout');
this._bounds = new go.Rect();
this._enclosingCircle = null;
// push all nodes in parts iterator to an array for easy sorting
const it = this.collectParts(coll).iterator;
const nodes = [];
let averageSize = 0;
let maxSize = 0;
while (it.next()) {
const node = it.value;
if (node instanceof go.Node) {
nodes.push(node);
averageSize += node.actualBounds.width + node.actualBounds.height;
if (node.actualBounds.width > maxSize) {
maxSize = node.actualBounds.width;
}
else if (node.actualBounds.height > maxSize) {
maxSize = node.actualBounds.height;
}
}
}
averageSize = averageSize / (nodes.length * 2);
if (averageSize < 1) {
averageSize = 1;
}
this.arrangementOrigin = this.initialOrigin(this.arrangementOrigin);
if (this.sortMode !== PackedLayout.None) {
if (!this.comparer) {
const sortOrder = this.sortOrder;
const sortMode = this.sortMode;
this.comparer = (a, b) => {
const sortVal = sortOrder === PackedLayout.Ascending ? 1 : -1;
if (sortMode === PackedLayout.MaxSide) {
const aMax = Math.max(a.actualBounds.width, a.actualBounds.height);
const bMax = Math.max(b.actualBounds.width, b.actualBounds.height);
if (aMax > bMax) {
return sortVal;
}
else if (bMax > aMax) {
return -sortVal;
}
return 0;
}
else if (sortMode === PackedLayout.Area) {
const area1 = a.actualBounds.width * a.actualBounds.height;
const area2 = b.actualBounds.width * b.actualBounds.height;
if (area1 > area2) {
return sortVal;
}
else if (area2 > area1) {
return -sortVal;
}
return 0;
}
return 0;
};
}
nodes.sort(this.comparer);
}
let targetWidth = this.size.width !== 0 ? this.size.width : 1;
let targetHeight = this.size.height !== 0 ? this.size.height : 1;
if (this._fillViewport && this.diagram !== null) {
targetWidth = this.diagram.viewportBounds.width !== 0 ? this.diagram.viewportBounds.width : 1;
targetHeight = this.diagram.viewportBounds.height !== 0 ? this.diagram.viewportBounds.height : 1;
}
else if (this._fillViewport) {
targetWidth = this._defaultSize.width !== 0 ? this._defaultSize.width : 1;
targetHeight = this._defaultSize.height !== 0 ? this._defaultSize.height : 1;
}
// set the target aspect ratio using the given bounds if necessary
if (this.packMode === PackedLayout.Fit || this.packMode === PackedLayout.ExpandToFit) {
this._eAspectRatio = targetWidth / targetHeight;
}
else {
this._eAspectRatio = this.aspectRatio;
}
let fits = this.hasCircularNodes || this.packShape === PackedLayout.Spiral ? this.fitCircles(nodes) : this.fitRects(nodes);
// in the Fit and ExpandToFit modes, we need to run the packing another time to figure out what the correct
// _fixedModeSpacing should be. Then the layout is run a final time with the correct spacing.
if (this.packMode === PackedLayout.Fit || this.packMode === PackedLayout.ExpandToFit) {
const bounds0 = this._bounds.copy();
this._bounds = new go.Rect();
this._fixedSizeModeSpacing = Math.floor(averageSize);
fits = this.hasCircularNodes || this.packShape === PackedLayout.Spiral ? this.fitCircles(nodes) : this.fitRects(nodes);
if ((this.hasCircularNodes || this.packShape === PackedLayout.Spiral) && this.packShape === PackedLayout.Spiral) {
const targetDiameter = Math.max(targetWidth, targetHeight);
const oldDiameter = targetDiameter === targetWidth ? bounds0.width : bounds0.height;
const newDiameter = targetDiameter === targetWidth ? this._bounds.width : this._bounds.height;
const diff = (newDiameter - oldDiameter) / this._fixedSizeModeSpacing;
this._fixedSizeModeSpacing = (targetDiameter - oldDiameter) / diff;
}
else {
const dx = (this._bounds.width - bounds0.width) / this._fixedSizeModeSpacing;
const dy = (this._bounds.height - bounds0.height) / this._fixedSizeModeSpacing;
const paddingX = (targetWidth - bounds0.width) / dx;
const paddingY = (targetHeight - bounds0.height) / dy;
this._fixedSizeModeSpacing = Math.abs(paddingX) > Math.abs(paddingY) ? paddingX : paddingY;
}
if (this.packMode === PackedLayout.Fit) {
// make sure that the spacing is not positive in this mode
this._fixedSizeModeSpacing = Math.min(this._fixedSizeModeSpacing, 0);
}
if (this._fixedSizeModeSpacing === Infinity) {
this._fixedSizeModeSpacing = -maxSize;
}
this._bounds = new go.Rect();
fits = this.hasCircularNodes || this.packShape === PackedLayout.Spiral ? this.fitCircles(nodes) : this.fitRects(nodes);
}
// move the nodes and calculate the actualBounds property
if (this.arrangesToOrigin) {
this._actualBounds = new go.Rect(this.arrangementOrigin.x, this.arrangementOrigin.y, 0, 0);
}
const nodeBounds = new Array(nodes.length);
for (let i = 0; i < nodes.length; i++) {
const fit = fits[i];
const node = nodes[i];
if (this.arrangesToOrigin) {
// translate coordinates to respect this.arrangementOrigin
// this.arrangementOrigin should be the top left corner of the bounding box around the layout
fit.x = fit.x - this._bounds.x + this.arrangementOrigin.x;
fit.y = fit.y - this._bounds.y + this.arrangementOrigin.y;
}
node.moveTo(fit.x, fit.y);
nodeBounds[i] = node.actualBounds;
this._actualBounds.unionRect(node.actualBounds);
}
this._nodeBounds = nodeBounds; // save node bounds in case we want to calculate the smallest enclosing circle later
// can be overriden to change layout behavior, doesn't do anything by default
this.commitLayout();
if (diagram !== null)
diagram.commitTransaction('Layout');
this.isValidLayout = true;
}
/**
* This method is called at the end of {@link #doLayout}, but
* before the layout transaction is committed. It can be overriden and
* used to customize layout behavior. By default, the method does nothing.
* @expose
* @this {PackedLayout}
*/
commitLayout() { }
/**
* @hidden @internal
* Runs a circle packing algorithm on the given array of nodes. The
* algorithm used is a slightly modified version of the one proposed
* by Wang et al. in "Visualization of large hierarchical data by
* circle packing", 2006.
* @this {PackedLayout}
* @param nodes the array of Nodes to pack
* @return {Array<Rect>} an array of positioned rectangles corresponding to the nodes argument
*/
fitCircles(nodes) {
function place(a, b, c) {
const ax = a.centerX;
const ay = a.centerY;
let da = (b.width + c.width) / 2;
let db = (a.width + c.width) / 2;
const dx = b.centerX - ax;
const dy = b.centerY - ay;
const dc = dx * dx + dy * dy;
if (dc) {
const x = 0.5 + ((db *= db) - (da *= da)) / (2 * dc);
const y = Math.sqrt(Math.max(0, 2 * da * (db + dc) - (db -= dc) * db - da * da)) / (2 * dc);
c.x = (ax + x * dx + y * dy) - (c.width / 2);
c.y = (ay + x * dy - y * dx) - (c.height / 2);
}
else {
c.x = ax + db;
c.y = ay;
}
return c;
}
function intersects(a, b) {
const ar = a.height / 2;
const br = b.height / 2;
const dist = Math.sqrt(a.center.distanceSquaredPoint(b.center));
const difference = dist - (ar + br);
return difference < -0.0000001;
}
const aspect = this._eAspectRatio;
const shape = this.packShape;
const placementCost = this.placementCost;
function score(n) {
const a = n.data;
const b = n.next.data;
const ar = a.width / 2;
const br = b.width / 2;
const ab = ar + br;
const dx = (a.centerX * br + b.centerX * ar) / ab;
const dy = (a.centerY * br + b.centerY * ar) / ab * aspect;
return shape === PackedLayout.Elliptical ? dx * dx + dy * dy : Math.max(dx * dx, dy * dy);
}
const sideSpacing = (this.spacing + this._fixedSizeModeSpacing) / 2;
const fits = [];
const frontChain = new CircularDoublyLinkedList();
if (!nodes.length)
return fits;
let n1 = nodes[0].actualBounds.copy().inflate(sideSpacing, sideSpacing);
n1.setTo(0, 0, n1.width === 0 ? 0.1 : n1.width, n1.height === 0 ? 0.1 : n1.height);
fits.push(n1.setTo(0, 0, n1.width, n1.height));
this._bounds.unionRect(n1);
if (nodes.length < 2)
return fits;
let n2 = nodes[1].actualBounds.copy().inflate(sideSpacing, sideSpacing);
n2.setTo(0, 0, n2.width === 0 ? 0.1 : n2.width, n2.height === 0 ? 0.1 : n2.height);
fits.push(n2.setTo(-n2.width, n1.centerY - n2.width / 2, n2.width, n2.height));
this._bounds.unionRect(n2);
if (nodes.length < 3)
return fits;
let n3 = nodes[2].actualBounds.copy().inflate(sideSpacing, sideSpacing);
n3.setTo(0, 0, n3.width === 0 ? 0.1 : n3.width, n3.height === 0 ? 0.1 : n3.height);
fits.push(place(n2, n1, n3));
this._bounds.unionRect(n3);
n2 = frontChain.push(n2);
n3 = frontChain.push(n3);
n1 = frontChain.push(n1);
pack: for (let i = 3; i < nodes.length; i++) {
n3 = nodes[i].actualBounds.copy().inflate(sideSpacing, sideSpacing);
n3.setTo(0, 0, n3.width === 0 ? 0.1 : n3.width, n3.height === 0 ? 0.1 : n3.height);
place(n1.data, n2.data, n3);
let j = n2.next;
let k = n1.prev;
let sj = n2.data.width / 2;
let sk = n1.data.width / 2;
do {
if (sj <= sk) {
if (intersects(j.data, n3)) {
n2 = frontChain.removeBetween(n1, j), i--;
continue pack;
}
sj += j.data.width / 2, j = j.next;
}
else {
if (intersects(k.data, n3)) {
frontChain.removeBetween(k, n2);
n1 = k, i--;
continue pack;
}
sk += k.data.width / 2, k = k.prev;
}
} while (j !== k.next);
fits.push(n3);
this._bounds.unionRect(n3);
n2 = n3 = frontChain.insertAfter(n3, n1);
if (this.packShape !== PackedLayout.Spiral) {
let aa = score(n1);
while ((n3 = n3.next) !== n2) {
const ca = score(n3);
if (ca < aa) {
n1 = n3, aa = ca;
}
}
n2 = n1.next;
}
}
return fits;
}
/**
* @hidden @internal
* Runs a rectangle packing algorithm on the given array of nodes.
* The algorithm presented is original, and operates by maintaining
* a representation (with segments) of the perimeter of the already
* packed shape. The perimeter of segments is stored in both a linked
* list (for ordered iteration) and a quadtree (for fast intersection
* detection). Similar to the circle packing algorithm presented
* above, this is a greedy algorithm.
*
* For each node, a large list of possible placements is created,
* each one relative to a segment on the perimeter. These placements
* are sorted according to a cost function, and then the lowest cost
* placement with no intersections is picked. The perimeter
* representation is then updated according to the new placement.
*
* However, in addition to placements made relative to a single segment
* on the perimeter, the algorithm also attempts to make placements
* between two nonsequential segments ("skip fits"), closing gaps in the
* packed shape. If a placement made in this way has no intersections
* and a lower cost than any of the original placements, it is picked
* instead. This step occurs simultaneously to checking intersections on
* the original placement list.
*
* Intersections for new placements are checked only against the current
* perimeter of segments, rather than the entire packed shape.
* Additionally, before the quadtree is queried at all, a few closely
* surrounding segments to the placement are checked in case an
* intersection can be found more quickly. The combination of these two
* strategies enables intersection checking to take place extremely
* quickly, when it would normally be the slowest part of the entire
* algorithm.
*
* @this {PackedLayout}
* @param nodes the array of Nodes to pack
* @return {Array<Rect>} an array of positioned rectangles corresponding to the nodes argument
*/
fitRects(nodes) {
const sideSpacing = (this.spacing + this._fixedSizeModeSpacing) / 2;
const fits = [];
const segments = new CircularDoublyLinkedList();
// reset layout state
this._tree.clear();
this._minXSegment = null;
this._maxXSegment = null;
this._minYSegment = null;
this._maxYSegment = null;
if (nodes.length < 1) {
return fits;
}
// place first node at 0, 0
const bounds0 = nodes[0].actualBounds;
fits.push(new go.Rect(sideSpacing, sideSpacing, bounds0.width, bounds0.height));
fits[0].inflate(sideSpacing, sideSpacing);
fits[0].setTo(0, 0, fits[0].width === 0 ? 0.1 : fits[0].width, fits[0].height === 0 ? 0.1 : fits[0].height);
this._bounds.unionRect(fits[0]);
this._center = fits[0].center;
const s1 = new Segment(0, 0, fits[0].width, 0, false);
const s2 = new Segment(fits[0].width, 0, fits[0].width, fits[0].height, false);
const s3 = new Segment(fits[0].width, fits[0].height, 0, fits[0].height, false);
const s4 = new Segment(0, fits[0].height, 0, 0, false);
this._tree.add(s1, this.rectFromSegment(s1));
this._tree.add(s2, this.rectFromSegment(s2));
this._tree.add(s3, this.rectFromSegment(s3));
this._tree.add(s4, this.rectFromSegment(s4));
segments.push(s1, s2, s3, s4);
this.fixMissingMinMaxSegments(true);
for (let i = 1; i < nodes.length; i++) {
const node = nodes[i];
const bounds = node.actualBounds.copy().inflate(sideSpacing, sideSpacing);
bounds.setTo(0, 0, bounds.width === 0 ? 0.1 : bounds.width, bounds.height === 0 ? 0.1 : bounds.height);
const possibleFits = new Array(segments.length);
let j = 0;
let s = segments.start;
do {
// make sure segment is perfectly straight (fixing some floating point error)
const sdata = s.data;
sdata.x1 = s.prev.data.x2;
sdata.y1 = s.prev.data.y2;
if (sdata.isHorizontal) {
sdata.y2 = sdata.y1;
}
else {
sdata.x2 = sdata.x1;
}
const fitBounds = this.getBestFitRect(s, bounds.width, bounds.height);
const cost = this.placementCost(fitBounds);
possibleFits[j] = new Fit(fitBounds, cost, s);
s = s.next;
j++;
} while (s !== segments.start);
possibleFits.sort((a, b) => {
return a.cost - b.cost;
});
/* scales the cost of skip fits. a number below
* one makes skip fits more likely to appear,
* which is preferable because they are more
* aesthetically pleasing and reduce the total
* number of segments.
*/
const skipFitScaleFactor = 0.98;
let bestFit = null;
let onlyCheckSkipFits = false;
for (const fit of possibleFits) {
if (bestFit && bestFit.cost <= fit.cost) {
onlyCheckSkipFits = true;
}
let hasIntersections = true; // set initially to true to make skip fit checking work when onlyCheckSkipFits = true
if (!onlyCheckSkipFits) {
hasIntersections = this.fastFitHasIntersections(fit) || this.fitHasIntersections(fit);
if (!hasIntersections) {
bestFit = fit;
continue;
}
}
// check skip fits
if (hasIntersections && !fit.s1.data.p1Concave && (fit.s1.next.data.p1Concave || fit.s1.next.next.data.p1Concave)) {
let [nextSegment, usePreviousSegment] = this.findNextOrientedSegment(fit, fit.s1.next);
let nextSegmentTouchesFit = false;
while (hasIntersections && nextSegment !== null) {
fit.bounds = this.rectAgainstMultiSegment(fit.s1, nextSegment, bounds.width, bounds.height);
hasIntersections = this.fastFitHasIntersections(fit) || this.fitHasIntersections(fit);
nextSegmentTouchesFit = this.segmentIsOnFitPerimeter(nextSegment.data, fit.bounds);
if (hasIntersections || !nextSegmentTouchesFit) {
[nextSegment, usePreviousSegment] = this.findNextOrientedSegment(fit, nextSegment);
}
}
if (!hasIntersections && nextSegment !== null && nextSegmentTouchesFit) {
fit.cost = this.placementCost(fit.bounds) * skipFitScaleFactor;
if (bestFit === null || fit.cost <= bestFit.cost) {
bestFit = fit;
bestFit.s2 = nextSegment;
if (usePreviousSegment) {
bestFit.s1 = bestFit.s1.prev;
}
}
}
}
}
if (bestFit !== null) {
this.updateSegments(bestFit, segments);
fits.push(bestFit.bounds);
this._bounds.unionRect(bestFit.bounds);
}
}
// save segments in case we want to calculate the enclosing circle later
this._segments = segments;
return fits;
}
/**
* @hidden @internal
* Attempts to find a segment which can be used to create a new skip fit
* between fit.s1 and the found segment. A number of conditions are checked
* before returning a segment, ensuring that if the skip fit *does* intersect
* with the already packed shape, it will do so along the perimeter (so that it
* can be detected with only knowledge about the perimeter). Multiple oriented
* segments can be found for a given fit, so this function starts searching at
* the segment after the given lastSegment parameter.
*
* Oriented segments can be oriented with either fit.s1, or fit.s1.prev. The
* second return value (usePreviousSegment) indicates which the found segment is.
*
* @this {PackedLayout}
* @param fit the fit to search for a new segment for
* @param lastSegment the last segment found.
*/
findNextOrientedSegment(fit, lastSegment) {
lastSegment = lastSegment.next;
const orientation = this.segmentOrientation(fit.s1.prev.data, fit.s1.data);
const targetOrientation = (orientation + 1) % 4;
while (!this.segmentIsMinOrMax(lastSegment.data)) {
const usePreviousSegment = lastSegment.data.isHorizontal === fit.s1.data.isHorizontal;
let lastOrientation;
if (usePreviousSegment) {
lastOrientation = this.segmentOrientation(lastSegment.data, lastSegment.next.data);
if (lastSegment.next.data.p1Concave) {
lastOrientation = (lastOrientation + 1) % 4;
}
}
else {
lastOrientation = this.segmentOrientation(lastSegment.prev.data, lastSegment.data);
if (lastSegment.data.p1Concave) {
lastOrientation = (lastOrientation + 1) % 4;
}
}
const validLastOrientation = lastOrientation === targetOrientation;
const exceededPrimaryDimension = fit.s1.data.isHorizontal ?
Math.abs(lastSegment.data.y1 - fit.s1.data.y1) + 1e-7 > fit.bounds.height :
Math.abs(lastSegment.data.x1 - fit.s1.data.x1) + 1e-7 > fit.bounds.width;
let validCornerPlacement;
let exceededSecondaryDimension;
switch (orientation) {
case Orientation.NE:
validCornerPlacement = fit.s1.data.x1 < lastSegment.data.x1;
exceededSecondaryDimension = usePreviousSegment ? fit.s1.data.y1 - fit.bounds.height >= lastSegment.data.y1 : fit.s1.data.y2 + fit.bounds.height <= lastSegment.data.y1;
break;
case Orientation.NW:
validCornerPlacement = fit.s1.data.y1 > lastSegment.data.y1;
exceededSecondaryDimension = usePreviousSegment ? fit.s1.data.x1 - fit.bounds.width >= lastSegment.data.x1 : fit.s1.data.x2 + fit.bounds.width <= lastSegment.data.x1;
break;
case Orientation.SW:
validCornerPlacement = fit.s1.data.x1 > lastSegment.data.x1;
exceededSecondaryDimension = usePreviousSegment ? fit.s1.data.y1 + fit.bounds.height <= lastSegment.data.y1 : fit.s1.data.y2 - fit.bounds.height >= lastSegment.data.y1;
break;
case Orientation.SE:
validCornerPlacement = fit.s1.data.y1 < lastSegment.data.y1;
exceededSecondaryDimension = usePreviousSegment ? fit.s1.data.x1 + fit.bounds.width <= lastSegment.data.x1 : fit.s1.data.x2 - fit.bounds.width >= lastSegment.data.x1;
break;
default:
throw new Error('Unknown orientation ' + orientation);
}
if (!exceededPrimaryDimension && !exceededSecondaryDimension && validCornerPlacement && validLastOrientation) {
return [lastSegment, usePreviousSegment];
}
lastSegment = lastSegment.next;
}
return [null, false];
}
/**
* @hidden @internal
* Returns the orientation of two adjacent segments. s2
* is assumed to start at the end of s1.
* @this {PackedLayout}
* @param s1 the first segment
* @param s2 the second segment
*/
segmentOrientation(s1, s2) {
if (s1.isHorizontal) {
if (s1.x1 < s2.x1) {
return s2.p1Concave ? Orientation.SE : Orientation.NE;
}
else {
return s2.p1Concave ? Orientation.NW : Orientation.SW;
}
}
else {
if (s1.y1 < s2.y1) {
return s2.p1Concave ? Orientation.SW : Orientation.SE;
}
else {
return s2.p1Concave ? Orientation.NE : Orientation.NW;
}
}
}
/**
* @hidden @internal
* Fits a rectangle between two segments (used for skip fits). This is an operation
* related more to corners than segments, so fit.s1 should always be supplied for
* segment a (even if usePreviousSegment was true in the return value for
* {@link #findNextOrientedSegment}).
*
* @this {PackedLayout}
* @param a the first segment to fit between, should always be fit.s1
* @param b the second segment to fit between, found with {@link #findNextOrientedSegment}
* @param width the width of the rectangle, should be fit.width
* @param height the height of the rectangle, should be fit.height
*/
rectAgainstMultiSegment(a, b, width, height) {
switch (this.segmentOrientation(a.prev.data, a.data)) {
case Orientation.NE:
if (a.data.y1 > b.data.y2) {
return new go.Rect(b.data.x1 - width, a.data.y1 - height, width, height);
}
else {
return new go.Rect(a.data.x1, b.data.y1 - height, width, height);
}
case Orientation.NW:
if (a.data.x1 > b.data.x2) {
return new go.Rect(a.data.x1 - width, b.data.y1, width, height);
}
else {
return new go.Rect(b.data.x1 - width, a.data.y1 - height, width, height);
}
case Orientation.SW:
if (a.data.y1 < b.data.y2) {
return new go.Rect(b.data.x1, a.data.y1, width, height);
}
else {
return new go.Rect(a.data.x1 - width, b.data.y1, width, height);
}
case Orientation.SE:
if (a.data.x1 < b.data.x2) {
return new go.Rect(a.data.x1, b.data.y1 - height, width, height);
}
else {
return new go.Rect(b.data.x1, a.data.y1, width, height);
}
}
}
/**
* @hidden @internal
* Gets the rectangle placed against the given segment with the lowest
* placement cost. Rectangles can be placed against a segment either at
* the top/left side, the bottom/right side, or at the center coordinate
* of the entire packed shape (if the segment goes through either the x
* or y coordinate of the center).
* @this {PackedLayout}
* @param s the segment to place against
* @param width the width of the fit, fit.width
* @param height the height of the fit, fit.height
*/
getBestFitRect(s, width, height) {
let x1 = s.data.x1;
let y1 = s.data.y1;
let x2 = s.data.x2;
let y2 = s.data.y2;
let dir = this.segmentOrientation(s.prev.data, s.data);
if (s.data.p1Concave) {
dir = (dir + 3) % 4;
}
const coordIsX = dir === Orientation.NW || dir === Orientation.SE;
if (dir === Orientation.NE) {
y2 -= height;
}
else if (dir === Orientation.SE) {
x1 -= width;
}
else if (dir === Orientation.SW) {
x1 -= width;
y1 -= height;
x2 -= width;
}
else if (dir === Orientation.NW) {
y1 -= height;
x2 -= width;
y2 -= height;
}
const r = new go.Rect(x1, y1, width, height);
const cost1 = this.placementCost(r);
const cost2 = this.placementCost(r.setTo(x2, y2, width, height));
let cost3 = Infinity;
if (coordIsX && (this._center.x - (x1 + width / 2)) * (this._center.x - (x2 + width / 2)) < 0) {
cost3 = this.placementCost(r.setTo(this._center.x - width / 2, y1, width, height));
}
else if (!coordIsX && (this._center.y - (y1 + height / 2)) * (this._center.y - (y2 + height / 2)) < 0) {
cost3 = this.placementCost(r.setTo(x1, this._center.y - height / 2, width, height));
}
return cost3 < cost2 && cost3 < cost1 ? r
: (cost2 < cost1 ? r.setTo(x2, y2, width, height)
: r.setTo(x1, y1, width, height));
}
/**
* @hidden @internal
* Checks if a segment is on the perimeter of the given fit bounds.
* Also returns true if the segment is within the rect, but that
* shouldn't matter for any of the cases where this function is used.
* @this {PackedLayout}
* @param s the segment to test
* @param bounds the fit bounds
*/
segmentIsOnFitPerimeter(s, bounds) {
const xCoordinatesTogether = this.numberIsBetween(s.x1, bounds.left, bounds.right)
|| this.numberIsBetween(s.x2, bounds.left, bounds.right)
|| this.numberIsBetween(bounds.left, s.x1, s.x2)
|| this.numberIsBetween(bounds.right, s.x1, s.x2);
const yCoordinatesTogether = this.numberIsBetween(s.y1, bounds.top, bounds.bottom)
|| this.numberIsBetween(s.y2, bounds.top, bounds.bottom)
|| this.numberIsBetween(bounds.top, s.y1, s.y2)
|| this.numberIsBetween(bounds.bottom, s.y1, s.y2);
return (s.isHorizontal && (this.approxEqual(s.y1, bounds.top) || this.approxEqual(s.y1, bounds.bottom)) && xCoordinatesTogether)
|| (!s.isHorizontal && (this.approxEqual(s.x1, bounds.left) || this.approxEqual(s.x1, bounds.right)) && yCoordinatesTogether);
}
/**
* @hidden @internal
* Checks if a point is on the perimeter of the given fit bounds.
* Also returns true if the point is within the rect, but that
* shouldn't matter for any of the cases where this function is used.
* @this {PackedLayout}
* @param x the x coordinate of the point to test
* @param y the y coordinate of the point to test
* @param bounds the fit bounds
*/
pointIsOnFitPerimeter(x, y, bounds) {
return (x >= bounds.left - 1e-7 && x <= bounds.right + 1e-7 && y >= bounds.top - 1e-7 && y <= bounds.bottom + 1e-7);
}
/**
* @hidden @internal
* Checks if a point is on the corner of the given fit bounds.
* @this {PackedLayout}
* @param x the x coordinate of the point to test
* @param y the y coordinate of the point to test
* @param bounds the fit bounds
*/
pointIsFitCorner(x, y, bounds) {
return (this.approxEqual(x, bounds.left) && this.approxEqual(y