Skip to content

Directed graph with viewGenerator #396

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: master
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
2 changes: 1 addition & 1 deletion sandbox/data/custom-node/custom-node.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default {
panAndZoom: false,
staticGraph: false,
width: 800,
directed: true,
node: {
color: "#d3d3d3",
fontColor: "black",
Expand All @@ -36,7 +37,6 @@ export default {
strokeColor: "none",
strokeWidth: 1.5,
svg: "",
symbolType: "circle",
viewGenerator: node => <CustomNode person={node} />,
},
link: {
Expand Down
5 changes: 3 additions & 2 deletions src/components/graph/graph.builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { isNil } from "../../utils";

import { buildLinkPathDefinition } from "../link/link.helper";
import { getMarkerId } from "../marker/marker.helper";
import { getNormalizedNodeCoordinates } from "./graph.helper";
import { getNormalizedNodeCoordinates } from "./graph.coords";

/**
* Get the correct node opacity in order to properly make decisions based on context such as currently highlighted node.
Expand Down Expand Up @@ -131,7 +131,8 @@ function buildLinkProps(link, nodes, links, config, linkCallbacks, highlightedNo
{ sourceId: source, targetId: target, sourceCoords: { x: x1, y: y1 }, targetCoords: { x: x2, y: y2 } },
nodes,
config,
strokeWidth
strokeWidth,
link.breakPoints
);

const d = buildLinkPathDefinition(
Expand Down
1 change: 1 addition & 0 deletions src/components/graph/graph.const.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default {
},
LINK_CLASS_NAME: "link",
NODE_CLASS_NAME: "node",
SYMBOLS_WITH_OPTIMIZED_POSITIONING: new Set([CONST.SYMBOLS.CIRCLE, CONST.SYMBOLS.SQUARE]),
TTL_DOUBLE_CLICK_IN_MS: 280,
...CONST,
};
330 changes: 330 additions & 0 deletions src/components/graph/graph.coords.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,330 @@
import CONST from "./graph.const";
import { logWarning } from "../../utils";

/**
* Computes the normalized vector from a vector.
*
* @param {Object} vector - A 2D vector with x and y components.
* @param {number} vector.x - The X coordinate.
* @param {number} vector.y - The Y coordinate.
* @returns {Object} Normalized vector.
* @memberof Graph/helper
*/
function normalize(vector) {
const norm = Math.sqrt(Math.pow(vector.x, 2) + Math.pow(vector.y, 2));
return norm === 0 ? vector : { x: vector.x / norm, y: vector.y / norm };
}

/**
* Calculates the vector length from the center of a rectangle to the closest edge following a direction.
* This calculation is taken from https://stackoverflow.com/a/3197924.
*
* @param {Object.<string, number>} RectangleCoords - The coords of the left-top vertex, and the right-bottom vertex.
* @param {Object.<string, number>} VectorOriginCoords - The center of the rectangle coords.
* @param {Object.<string, number>} directionVector - A 2D vector with x and y components.
* @returns {number} The length of the vector from the center of the symbol to it's closet edge, considering the given direction vector.
*/
function calcRectangleVectorLengthFromCoords({ x1, y1, x2, y2 }, { px, py }, directionVector) {
const angle = Math.atan(directionVector.y / directionVector.x);

const vx = Math.cos(angle);
const vy = Math.sin(angle);

if (vx === 0) {
return x2 - x1;
} else if (vy === 0) {
return y2 - y1;
}

const leftEdge = (x1 - px) / vx;
const rightEdge = (x2 - px) / vx;

const topEdge = (y1 - py) / vy;
const bottomEdge = (y2 - py) / vy;

return Math.min(...[leftEdge, rightEdge, topEdge, bottomEdge].filter(edge => edge > 0));
}

/**
* Calculates the radius of the node.
*
* @param {number} nodeSize - The size of the circle, when no viewGenerator is specified, else the size of an edge of the viewGenerator square.
* @param {boolean} isCustomNode - Is viewGenerator specified.
* @returns {number} The length of the vector from the center of the symbol to it's closet edge, considering the given direction vector.
*/
function calcCircleVectorLength(nodeSize, isCustomNode) {
let radiusLength;
if (isCustomNode) {
// nodeSize equals the Diameter in the case of custome-node.
radiusLength = nodeSize / 10 / 2;
} else {
// because this is a circle and A = pi * r^2.
radiusLength = Math.sqrt(nodeSize / Math.PI);
}
return radiusLength;
}

/**
* Calculates the vector length from the center of a square to the closest edge following a direction.
*
* @param {number} nodeSize - The size of the square, when no viewGenerator is specified, else the size of an edge of the viewGenerator square.
* @param {Object.<string, number>} nodeCoords - The coords of a the square node.
* @param {Object.<string, number>} directionVector - A 2D vector with x and y components.
* @param {boolean} isCustomNode - Is viewGenerator specified.
* @returns {number} The length of the vector from the center of the symbol to it's closet edge, considering the given direction vector.
*/
function calcSquareVectorLength(nodeSize, { x, y }, directionVector, isCustomNode) {
let edgeSize;
if (isCustomNode) {
// nodeSize equals the edgeSize in the case of custome-node.
edgeSize = nodeSize / 10;
} else {
// All the edges of a square are equal, inorder to calc its size we multplie two edges.
edgeSize = Math.sqrt(nodeSize);
}

// The x and y coords represent the top center of the component
const leftSquareX = x - edgeSize / 2;
const topSquareY = y - edgeSize / 2;

return calcRectangleVectorLengthFromCoords(
{ x1: leftSquareX, y1: topSquareY, x2: leftSquareX + edgeSize, y2: topSquareY + edgeSize },
{ px: x, py: y },
directionVector
);
}

/**
* Calculates the vector length from the center of a rectangle to the closest edge following a direction.
*
* @param {number} nodeSize - The size of the square, when no viewGenerator is specified, else the size of an edge of the viewGenerator square.
* @param {Object.<string, number>} nodeCoords - The coords of a the square node.
* @param {Object.<string, number>} directionVector - A 2D vector with x and y components.
* @returns {number} The length of the vector from the center of the symbol to it's closet edge, considering the given direction vector.
*/
function calcRectangleVectorLength(nodeSize, { x, y }, directionVector) {
const horizEdgeSize = nodeSize.width / 10;
const vertEdgeSize = nodeSize.height / 10;

// The x and y coords in this library, represent the top center of the component.
const leftSquareX = x - horizEdgeSize / 2;
const topSquareY = y - vertEdgeSize / 2;

// The size between the square center, to the appropriate square edges
return calcRectangleVectorLengthFromCoords(
{ x1: leftSquareX, y1: topSquareY, x2: leftSquareX + horizEdgeSize, y2: topSquareY + vertEdgeSize },
{ px: x, py: y },
directionVector
);
}

/**
* Calculate a the vector length of symbol that included in symbols with optimized positioning.
*
* @param {string} symbolType - The string that specifies the symbol type (should be one of {@link #node-symbol-type|node.symbolType}).
* @param {(number | Object.<string, number>)} nodeSize - The size of the square, when no viewGenerator is specified, else the size of an edge of the viewGenerator square.
* @param {Object.<string, number>} nodeCoords - The coords of a the square node.
* @param {Object.<string, number>} directionVector - A 2D vector with x and y components.
* @param {boolean} isCustomNode - Is viewGenerator specified.
* @returns {number} The length of the vector from the center of the symbol to it's closet edge, considering the given direction vector.
*/
function calcVectorLength(symbolType, nodeSize, { x, y }, directionVector, isCustomNode) {
if (isCustomNode && typeof nodeSize === "object" && nodeSize?.width && nodeSize?.height) {
return calcRectangleVectorLength(nodeSize, { x, y }, directionVector);
Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems strange that we are hacking the symbol type whenever a width and height are provided. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Size as an object, currently only makes sense when a viewGenerator is provided.
This issue predates this PR.
Do you think a special SYMBOL that only supported with viewGenerator is the answer?

}

switch (symbolType) {
case CONST.SYMBOLS.CIRCLE:
if (typeof nodeSize !== "number") {
logWarning("When specifiying 'circle' as node symbol, the size of the node must be a number.");
break;
}
return calcCircleVectorLength(nodeSize, isCustomNode);

case CONST.SYMBOLS.SQUARE:
if (typeof nodeSize !== "number") {
logWarning("When specifiying 'square' as node symbol, the size of the node must be a number.");
break;
}
return calcSquareVectorLength(nodeSize, { x, y }, directionVector, isCustomNode);
}

return 1;
}

/**
* When directed graph is specified, we add arrow head to the link.
* In order to add the arrow head we subtract its size from the last point of the link.
*
* @param {number} p1 - x or y coordinate, of the link last point.
* @param {number} p2 - x or y coordinate, of the link ending point.
* @param {number} pDirectionVector - The link direction vector in the x or y axis.
* @param {number} arrowSize - The size of the arrow head.
* @returns {number} The amount we should add to the x or y coords, in order to free up space for the arrow head.
*/
function directedGraphCoordsOptimization(p1, p2, pDirectionVector, arrowSize) {
const pDiff = Math.abs(p2 - p1);
const invertedDirectionVector = pDirectionVector * -1;
const pVectorArrowSize = Math.abs(arrowSize * invertedDirectionVector);

let p2opti;
if (pDiff > pVectorArrowSize) {
p2opti = arrowSize * invertedDirectionVector;
} else {
p2opti = (pDiff - 1) * invertedDirectionVector;
}

return p2opti;
}

/**
* When directed graph is specified, we add arrow head to the link.
* In order to add the arrow head we subtract its size from the last point of the link.
*
* @param {Object.<string, number>} optimizedTargetCoords - The modified coords of the target node.
* @param {Object.<string, number>} prevCoords - The coords of a the last point in the link (last link.breakPoints or the sourceCoords).
* @param {Object.<string, number>} directionVector - A 2D vector with x and y components.
* @param {number} arrowSize - The size of the arrow head.
* @param {Object.<string, number>} targetCoords - The initial coords of the target node.
* @param {(number | Object.<string, number>)} targetNodeSize - The target node size.
* @param {boolean} isCustomNode - Is viewGenerator specified.
* @returns {Object.<string, number>} The amount we should add to the x or y coords, in order to free up space for the arrow head.
*/
function directedGraphOptimization(
{ x: trgX, y: trgY },
{ x: prevX, y: prevY },
directionVector,
arrowSize,
targetCoords,
targetNodeSize,
isCustomNode
) {
// Check if the last link coord overlaps with the target node.
if (isCustomNode && typeof targetNodeSize === "object" && targetNodeSize?.width && targetNodeSize?.height) {
const targetNodeWidth = targetNodeSize.width / 10;
const targetNodeHeight = targetNodeSize.height / 10;

const leftTargetNodeRectangleX = targetCoords.x - targetNodeWidth / 2;
const xOverlaps = leftTargetNodeRectangleX < prevX && prevX < leftTargetNodeRectangleX + targetNodeWidth;

const topTargetNodeRectangleY = targetCoords.y - targetNodeHeight / 2;
const yOverlaps = topTargetNodeRectangleY < prevY && prevY < topTargetNodeRectangleY + targetNodeHeight;

if (xOverlaps && yOverlaps) {
return targetCoords;
}
}
const optTrgX = directedGraphCoordsOptimization(prevX, trgX, directionVector.x, arrowSize);
const newTrgX = trgX + optTrgX;

const optTrgY = directedGraphCoordsOptimization(prevY, trgY, directionVector.y, arrowSize);
const newTrgY = trgY + optTrgY;

return { x: newTrgX, y: newTrgY };
}

/**
* Computes new node coordinates to make arrowheads point at nodes.
* Arrow configuration is only available for circles, squares and rectangles.
*
* @param {Object} info - The couple of nodes we need to compute new coordinates.
* @param {string} info.sourceId - Node source id.
* @param {string} info.targetId - Node target id.
* @param {Object} info.sourceCoords - Node source coordinates.
* @param {Object} info.targetCoords - Node target coordinates.
* @param {Object.<string, Object>} nodes - Same as {@link #graphrenderer|nodes in renderGraph}.
* @param {Object} config - Same as {@link #graphrenderer|config in renderGraph}.
* @param {number} strokeWidth - Width of the link stroke.
* @param {Array.<Object>} breakPoints - Additional set of points that the link will cross.
* @returns {Object} new nodes coordinates
* @memberof Graph/helper
*/
function getNormalizedNodeCoordinates(
{ sourceId, targetId, sourceCoords = {}, targetCoords = {} },
nodes,
config,
strokeWidth,
breakPoints = []
) {
const sourceNode = nodes?.[sourceId];
const targetNode = nodes?.[targetId];

if (!sourceNode || !targetNode) {
return { sourceCoords, targetCoords };
}

const sourceSymbolType = sourceNode.symbolType || config.node?.symbolType;
const targetSymbolType = targetNode.symbolType || config.node?.symbolType;

const sourceWithOptimizedPositioning = CONST.SYMBOLS_WITH_OPTIMIZED_POSITIONING.has(sourceSymbolType);
const sourceRectangleWithViewGenerator =
!!(sourceNode?.viewGenerator || config.node?.viewGenerator) &&
!!(typeof (sourceNode?.size || config.node?.size) === "object");

const targetWithOptimizedPositioning = CONST.SYMBOLS_WITH_OPTIMIZED_POSITIONING.has(targetSymbolType);
const targetRectangleWithViewGenerator =
!!(targetNode?.viewGenerator || config.node?.viewGenerator) &&
!!(typeof (sourceNode?.size || config.node?.size) === "object");

if (
!(sourceWithOptimizedPositioning || sourceRectangleWithViewGenerator) &&
!(targetWithOptimizedPositioning || targetRectangleWithViewGenerator)
) {
// if symbols don't have optimized positioning implementations fallback to input coords
return { sourceCoords, targetCoords };
}

let { x: srcX, y: srcY } = sourceCoords;
let { x: trgX, y: trgY } = targetCoords;

const { x: nextX, y: nextY } = breakPoints.length > 0 ? breakPoints[0] : targetCoords;
const firstDirectionVector = normalize({ x: nextX - srcX, y: nextY - srcY });

const isSourceCustomNode = sourceNode?.viewGenerator || config.node?.viewGenerator;
const sourceNodeSize = sourceNode?.size || config.node?.size;

const sourceVectorLength =
calcVectorLength(sourceSymbolType, sourceNodeSize, { x: srcX, y: srcY }, firstDirectionVector, isSourceCustomNode) *
0.95;

srcX += sourceVectorLength * firstDirectionVector.x;
srcY += sourceVectorLength * firstDirectionVector.y;

const { x: prevX, y: prevY } = breakPoints.length > 0 ? breakPoints[breakPoints.length - 1] : { x: srcX, y: srcY };
const secondDirectionVector = normalize({ x: trgX - prevX, y: trgY - prevY });

// it's fine `markerWidth` or `markerHeight` we just want to fallback to a number
// to avoid NaN on `Math.min(undefined, undefined) > NaN
const strokeSize = strokeWidth * Math.min(config.link?.markerWidth || 5, config.link?.markerHeight || 5);
const isTargetCustomNode = targetNode?.viewGenerator || config.node?.viewGenerator;
const targetNodeSize = targetNode?.size || config.node?.size;

const targetVectorLength =
calcVectorLength(
targetSymbolType,
targetNodeSize,
{ x: trgX, y: trgY },
secondDirectionVector,
isTargetCustomNode
) * 0.95;

const arrowSize = config.directed ? strokeSize : 0;
trgX -= targetVectorLength * secondDirectionVector.x;
trgY -= targetVectorLength * secondDirectionVector.y;

const { x: newTrgX, y: newTrgY } = directedGraphOptimization(
{ x: trgX, y: trgY },
{ x: prevX, y: prevY },
secondDirectionVector,
arrowSize,
targetCoords,
targetNodeSize,
isTargetCustomNode
);
trgX = newTrgX;
trgY = newTrgY;

return { sourceCoords: { x: srcX, y: srcY }, targetCoords: { x: trgX, y: trgY } };
}

export { getNormalizedNodeCoordinates };
Loading