/* * 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/FishboneLayout.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 learn page (https://gojs.net/learn/extensions) for more information. */ import go from 'gojs'; /** * FishboneLayout is a custom {@link go.Layout} derived from {@link go.TreeLayout} for creating "fishbone" diagrams. * A fishbone diagram also requires a {@link go.Link} class that implements custom routing, {@link FishboneLink}. * * This only works for angle === 0 or angle === 180. * * This layout assumes Links are automatically routed in the way needed by fishbone diagrams, * by using the FishboneLink class instead of go.Link. * * If you want to experiment with this extension, try the Fishbone Layout sample. * @category Layout Extension */ export class FishboneLayout extends go.TreeLayout { private _uniformBranches: boolean; private _branchesRight: boolean; /** * Constructs a FishboneLayout and sets the following properties: * - {@link TreeLayout.alignment} = {@link go.TreeAlignment.BusBranching} * - {@link TreeLayout.setsPortSpot} = false * - {@link TreeLayout.setsChildPortSpot} = false */ constructor(init?: Partial) { super(); this.alignment = go.TreeAlignment.BusBranching; this.setsPortSpot = false; this.setsChildPortSpot = false; this._uniformBranches = false; this._branchesRight = false; if (init) Object.assign(this, init); } /** * Gets or sets whether detail branches are placed on one or both sides of each rib branch. * The side of the rib for detail branches is determined by {@link branchesRight}. * * Default is false. */ get uniformBranches(): boolean { return this._uniformBranches; } set uniformBranches(val: boolean) { val = !!val; if (this._uniformBranches !== val) { this._uniformBranches = val; this.invalidateLayout(); } } /** * Gets or sets which side of the rib the detail branches occupy when {@link uniformBranches} is true. * * Default is false. */ get branchesRight(): boolean { return this._branchesRight; } set branchesRight(val: boolean) { val = !!val; if (this._branchesRight !== val) { this._branchesRight = val; this.invalidateLayout(); } } /** * Copies properties to a cloned Layout. */ override cloneProtected(copy: this): void { super.cloneProtected(copy); copy._uniformBranches = this._uniformBranches; copy._branchesRight = this._branchesRight; } /** * Create and initialize a {@link go.LayoutNetwork} with the given nodes and links. * This override creates dummy vertexes, when necessary, to allow for proper positioning within the fishbone. * @param coll - A {@link go.Diagram} or a {@link go.Group} or a collection of {@link go.Part}s. */ override makeNetwork( coll: go.Diagram | go.Group | go.Iterable ): go.LayoutNetwork { // assert(this.angle === 0 || this.angle === 180); // assert(this.alignment === go.TreeAlignment.BusBranching); // assert(this.path !== go.TreePath.Source); // call base method for standard behavior const net = super.makeNetwork(coll); // make a copy of the collection of TreeVertexes // because we will be modifying the TreeNetwork.vertexes collection in the loop const verts = new go.List().addAll( net.vertexes.iterator as go.Iterator ); // horizontally-branching children that each need a zero-size row-mate dummy const vertical = this.uniformBranches ? this.findVerticalVertexes(verts) : null; verts.each((v: go.TreeVertex) => { // ignore leaves of tree if (v.destinationEdges.count === 0) return; this.prepareParent(v, net as go.TreeNetwork); if (vertical !== null && vertical.has(v)) { // pair each real child with a zero-size dummy occupying the other half of its row // the children order comes from the destinationEdges order // interleaving the edges here places every real child on the side chosen by branchesRight const realEdges: Array = []; v.destinationEdges.each((e) => realEdges.push(e)); realEdges.forEach((e) => v.deleteDestinationEdge(e)); realEdges.forEach((e) => { if (!this.branchesRight) v.addDestinationEdge(e); const dummy = net.createVertex(); dummy.bounds = new go.Rect(); dummy.focus = new go.Point(); net.addVertex(dummy); net.linkVertexes(v, dummy, null); if (this.branchesRight) v.addDestinationEdge(e); }); } else if (v.destinationEdges.count % 2 === 1) { // if there's an odd number of real children, add two dummies const dummy = net.createVertex(); dummy.bounds = new go.Rect(); dummy.focus = new go.Point(); net.addVertex(dummy); net.linkVertexes(v, dummy, null); } // make sure there's an odd number of children, including at least one dummy; // commitNodes will move the parent node to where this dummy child node is placed const dummy2 = net.createVertex(); dummy2.bounds = v.bounds; dummy2.focus = v.focus; net.addVertex(dummy2); net.linkVertexes(v, dummy2, null); }); return net; } /** @hidden */ prepareParent(v: go.TreeVertex, net: go.TreeNetwork) {} /** * @hidden * Collect the vertexes at odd depths from each root whose children branch off horizontally. */ findVerticalVertexes(verts: go.List): go.Set { const vertical = new go.Set(); const seen = new go.Set(); verts.each((r) => { if (r.sourceEdges.count > 0 || r.destinationEdges.count === 0) return; let frontier: Array = [r]; let odd = true; while (frontier.length > 0) { const next: Array = []; for (const u of frontier) { u.destinationEdges.each((e) => { const w = e.toVertex as go.TreeVertex; if (w === null || seen.has(w)) return; seen.add(w); if (odd) vertical.add(w); next.push(w); }); } frontier = next; odd = !odd; } }); return vertical; } /** * Add a direction property to each vertex and modify {@link go.TreeVertex.layerSpacing}. */ override assignTreeVertexValues(v: go.TreeVertex): void { super.assignTreeVertexValues(v); (v as any)['_direction'] = 0; // add this property to each TreeVertex if (v.parent !== null) { // The parent node will be moved to where the last dummy will be; // reduce the space to account for the future hole. if (v.angle === 0 || v.angle === 180) { v.layerSpacing -= v.bounds.width; } else { v.layerSpacing -= v.bounds.height; if (this.uniformBranches) v.layerSpacing -= v.bounds.height; } } } /** * Assigns {@link go.Link.fromSpot}s and {@link go.Link.toSpot}s based on branching and angle * and moves vertexes based on dummy locations. */ override commitNodes(): void { if (this.network === null) return; // vertex Angle is set by BusBranching "inheritance"; // assign spots assuming overall Angle === 0 or 180 // and links are always connecting horizontal with vertical this.network.edges.each((e) => { const link = e.link; if (link === null) return; link.fromSpot = go.Spot.None; link.toSpot = go.Spot.None; const v: go.TreeVertex = e.fromVertex as go.TreeVertex; const w: go.TreeVertex = e.toVertex as go.TreeVertex; if (v.angle === 0) { link.fromSpot = go.Spot.Left; } else if (v.angle === 180) { link.fromSpot = go.Spot.Right; } if (w.angle === 0) { link.toSpot = go.Spot.Left; } else if (w.angle === 180) { link.toSpot = go.Spot.Right; } }); this.moveParentNodesToDummies(); this.network.vertexes.each((v) => { const w = v as go.TreeVertex; if (w.parent === null) { this.shift(w); } }); // now actually change the Node.location of all nodes super.commitNodes(); } /** * Move each parent node to the location of the last dummy. */ moveParentNodesToDummies() { const vit = this.network!.vertexes.iterator; while (vit.next()) { const v = vit.value as go.TreeVertex; const len = v.children.length; if (len === 0) continue; // ignore leaf nodes if (v.parent === null) continue; // don't move root node const dummy2 = v.children[len - 1]; v.centerX = dummy2.centerX; v.centerY = dummy2.centerY; } } /** * This override stops links from being committed since the work is done by the {@link FishboneLink} class. */ override commitLinks(): void {} /** * Shifts subtrees within the fishbone based on angle and node spacing. */ shift(v: go.TreeVertex): void { const p = v.parent; if (p !== null && (v.angle === 90 || v.angle === 270)) { const g = p.parent; if (g !== null) { const shift = v.nodeSpacing; if ((g as any)['_direction'] > 0) { if (g.angle === 90) { if (p.angle === 0) { (v as any)['_direction'] = 1; if (v.angle === 270) this.shiftAll(2, -shift, p, v); } else if (p.angle === 180) { (v as any)['_direction'] = -1; if (v.angle === 90) this.shiftAll(-2, shift, p, v); } } else if (g.angle === 270) { if (p.angle === 0) { (v as any)['_direction'] = 1; if (v.angle === 90) this.shiftAll(2, -shift, p, v); } else if (p.angle === 180) { (v as any)['_direction'] = -1; if (v.angle === 270) this.shiftAll(-2, shift, p, v); } } } else if ((g as any)['_direction'] < 0) { if (g.angle === 90) { if (p.angle === 0) { (v as any)['_direction'] = 1; if (v.angle === 90) this.shiftAll(2, -shift, p, v); } else if (p.angle === 180) { (v as any)['_direction'] = -1; if (v.angle === 270) this.shiftAll(-2, shift, p, v); } } else if (g.angle === 270) { if (p.angle === 0) { (v as any)['_direction'] = 1; if (v.angle === 270) this.shiftAll(2, -shift, p, v); } else if (p.angle === 180) { (v as any)['_direction'] = -1; if (v.angle === 90) this.shiftAll(-2, shift, p, v); } } } } else { // g === null: V is a child of the tree ROOT const dir = p.angle === 0 ? 1 : -1; (v as any)['_direction'] = dir; this.shiftAll(dir, 0, p, v); } } for (let i = 0; i < v.children.length; i++) { const c = v.children[i]; this.shift(c); } } /** * Shifts a subtree. */ shiftAll( direction: number, absolute: number, root: go.TreeVertex, v: go.TreeVertex ): void { // assert(root.angle === 0 || root.angle === 180); let locx = v.centerX; locx += (direction * Math.abs(root.centerY - v.centerY)) / 2; locx += absolute; v.centerX = locx; for (let i = 0; i < v.children.length; i++) { const c = v.children[i]; this.shiftAll(direction, absolute, root, c); } } // end FishboneLayout } /** * Custom {@link go.Link} class for {@link FishboneLayout}. * @category Part Extension */ export class FishboneLink extends go.Link { constructor(init?: Partial) { super(); if (init) Object.assign(this, init); } override computeAdjusting(): go.LinkAdjusting { return this.adjusting; } /** * Determines the points for this link based on spots and maintains horizontal lines. */ override computePoints(): boolean { const result = super.computePoints(); if (result) { // insert middle point to maintain horizontal lines if (this.fromSpot.equals(go.Spot.Right) || this.fromSpot.equals(go.Spot.Left)) { let p1: go.Point; // deal with root node being on the "wrong" side const fromnode = this.fromNode; const fromport = this.fromPort; if ( fromnode !== null && fromport !== null && fromnode.findLinksInto().count === 0 ) { // pretend the link is coming from the opposite direction than the declared FromSpot const fromctr = fromport.getDocumentPoint(go.Spot.Center); const fromfar = fromctr.copy(); fromfar.x += this.fromSpot.equals(go.Spot.Left) ? 99999 : -99999; p1 = this.getLinkPointFromPoint( fromnode, fromport, fromctr, fromfar, true ).copy(); // update the route points this.setPoint(0, p1); let endseg = this.fromEndSegmentLength; if (isNaN(endseg)) endseg = fromport.fromEndSegmentLength; p1.x += this.fromSpot.equals(go.Spot.Left) ? endseg : -endseg; this.setPoint(1, p1); } else { p1 = this.getPoint(1); // points 0 & 1 should be OK already } const tonode = this.toNode; const toport = this.toPort; if (tonode !== null && toport !== null) { const toctr = toport.getDocumentPoint(go.Spot.Center); const far = toctr.copy(); far.x += this.fromSpot.equals(go.Spot.Left) ? -99999 / 2 : 99999 / 2; far.y += toctr.y < p1.y ? 99999 : -99999; const p2 = this.getLinkPointFromPoint(tonode, toport, toctr, far, false); this.setPoint(2, p2); let dx = Math.abs(p2.y - p1.y) / 2; if (this.fromSpot.equals(go.Spot.Left)) dx = -dx; this.insertPoint(2, new go.Point(p2.x + dx, p1.y)); } } else if (this.toSpot.equals(go.Spot.Right) || this.toSpot.equals(go.Spot.Left)) { const p1: go.Point = this.getPoint(1); // points 1 & 2 should be OK already const fromnode = this.fromNode; const fromport = this.fromPort; if (fromnode !== null && fromport !== null) { const parentlink = fromnode.findLinksInto().first(); const fromctr = fromport.getDocumentPoint(go.Spot.Center); const far = fromctr.copy(); far.x += parentlink !== null && parentlink.fromSpot.equals(go.Spot.Left) ? -99999 / 2 : 99999 / 2; far.y += fromctr.y < p1.y ? 99999 : -99999; const p0 = this.getLinkPointFromPoint(fromnode, fromport, fromctr, far, true); this.setPoint(0, p0); let dx = Math.abs(p1.y - p0.y) / 2; if (parentlink !== null && parentlink.fromSpot.equals(go.Spot.Left)) dx = -dx; this.insertPoint(1, new go.Point(p0.x + dx, p1.y)); } } } return result; } }