Skip to content

Add a 'direction' option for breadthfirst layout that can be 'top-bottom', 'bottom-top', 'left-right', or 'right-left' #3373

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: unstable
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3247,6 +3247,7 @@ declare namespace cytoscape {
* A boolean indicating whether the algorithm should only go along edges from source to target (default false).
*/
directed?: boolean;
direction?: 'top-bottom' | 'bottom-top' | 'left-right' | 'right-left';
}
interface SearchFirstOptions1 extends SearchFirstOptionsBase {
/**
Expand Down
21 changes: 14 additions & 7 deletions src/extensions/layout/breadthfirst.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as is from '../../is.mjs';
const defaults = {
fit: true, // whether to fit the viewport to the graph
directed: false, // whether the tree is directed downwards (or edges can point in any direction if false)
direction: 'top-bottom', // 'top-bottom', 'bottom-top', 'left-right', or 'right-left'
Copy link
Preview

Copilot AI May 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new direction option should be documented in the public API docs or JSDoc so users know the full set of valid values and their effects.

Copilot uses AI. Check for mistakes.

padding: 30, // padding on fit
circle: false, // put depths in concentric circles if true, put depths top down if false
grid: false, // whether to create an even grid into which the DAG is placed (circle:false only)
Expand Down Expand Up @@ -48,10 +49,8 @@ BreadthFirstLayout.prototype.run = function(){
const maximal = options.acyclic || options.maximal || options.maximalAdjustments > 0; // maximalAdjustments for compat. w/ old code; also, setting acyclic to true sets maximal to true

const hasBoundingBox = !!options.boundingBox;
const cyExtent = cy.extent();
const bb = math.makeBoundingBox( hasBoundingBox ? options.boundingBox : {
x1: cyExtent.x1, y1: cyExtent.y1, w: cyExtent.w, h: cyExtent.h
} );
const bb = math.makeBoundingBox( hasBoundingBox ? options.boundingBox :
structuredClone(cy.extent()));

let roots;
if( is.elementOrCollection( options.roots ) ){
Expand Down Expand Up @@ -350,7 +349,7 @@ BreadthFirstLayout.prototype.run = function(){

const maxDepthSize = depths.reduce( (max, eles) => Math.max(max, eles.length), 0 );

const getPosition = function( ele ){
const getPositionTopBottom = function( ele ){
const { depth, index } = getInfo( ele );

if ( options.circle ){
Expand Down Expand Up @@ -386,10 +385,18 @@ BreadthFirstLayout.prototype.run = function(){

return epos;
}

};

eles.nodes().layoutPositions( this, options, getPosition );
const rotateDegrees = {
'bottom-top': 180,
'left-right': -90,
'right-left': 90,
'top-bottom': 0,
}

const getPosition = (ele) => util.rotatePosAndSkewByBox(getPositionTopBottom(ele), bb, rotateDegrees[options.direction] ?? 0);
Copy link
Preview

Copilot AI May 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code silently defaults to 0° if options.direction is invalid. It would be safer to validate options.direction against the allowed values early and warn or throw on invalid input.

Copilot uses AI. Check for mistakes.


eles.nodes().layoutPositions( this, options, getPosition);

return this; // chaining
};
Expand Down
1 change: 1 addition & 0 deletions src/util/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './maps.mjs';
export * from './strings.mjs';
export * from './timing.mjs';
export * from './hash.mjs';
export * from './position.mjs';

export { strings, extend, extend as assign, memoize, regex, sort };

Expand Down
33 changes: 33 additions & 0 deletions src/util/position.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export function rotatePoint(x, y, centerX, centerY, angleDegrees) {
const angleRadians = (angleDegrees * Math.PI) / 180;
const rotatedX =
Math.cos(angleRadians) * (x - centerX) -
Math.sin(angleRadians) * (y - centerY) +
centerX;
const rotatedY =
Math.sin(angleRadians) * (x - centerX) +
Math.cos(angleRadians) * (y - centerY) +
centerY;
return { x: rotatedX, y: rotatedY };
}

export const skewPointInBox = (x, y, boxX, boxY, skewX, skewY) => ({
x: (x - boxX) * skewX + boxX,
y: (y - boxY) * skewY + boxY
});

export function rotatePosAndSkewByBox(pos, box, angleDegrees) {
if (angleDegrees === 0) return pos;
const centerX = (box.x1 + box.x2) / 2;
const centerY = (box.y1 + box.y2) / 2;
const skewX = box.w / box.h;
const skewY = 1 / skewX;

const rotated = rotatePoint(pos.x, pos.y, centerX, centerY, angleDegrees);
const skewed = skewPointInBox(rotated.x, rotated.y, centerX, centerY, skewX, skewY);

return {
x: skewed.x,
y: skewed.y,
};
};