fix(ui): use horizontal attack path graph layout

This commit is contained in:
Alan Buscaglia
2026-05-06 08:17:32 +02:00
parent 435010fe9a
commit b84c66abfd
7 changed files with 39 additions and 30 deletions
@@ -32,7 +32,7 @@ const buildNodeProps = (graphNode: GraphNode): NodeProps =>
}) as unknown as NodeProps;
describe("FindingNode", () => {
it("positions graph handles for vertical top-to-bottom edges", () => {
it("positions graph handles for horizontal left-to-right edges", () => {
// Given
const props = buildNodeProps(
buildFindingNode("critical", "Root key exposed"),
@@ -44,8 +44,10 @@ describe("FindingNode", () => {
// Then
expect(hiddenHandlesMock).toHaveBeenCalledWith(
expect.objectContaining({
sourcePosition: Position.Bottom,
targetPosition: Position.Top,
sourcePosition: Position.Right,
sourceStyle: { left: 97, top: 26 },
targetPosition: Position.Left,
targetStyle: { left: 53, top: 26 },
}),
undefined,
);
@@ -21,8 +21,8 @@ const BADGE_SIZE = 44;
const BADGE_RADIUS = BADGE_SIZE / 2;
const BADGE_CENTER_X = NODE_WIDTH / 2;
const BADGE_CENTER_Y = 26;
const BADGE_BOTTOM_Y = BADGE_CENTER_Y + BADGE_RADIUS;
const BADGE_TOP_Y = BADGE_CENTER_Y - BADGE_RADIUS;
const BADGE_LEFT_X = BADGE_CENTER_X - BADGE_RADIUS;
const BADGE_RIGHT_X = BADGE_CENTER_X + BADGE_RADIUS;
const ICON_SIZE = 28;
const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2;
const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
@@ -76,11 +76,10 @@ export const FindingNode = ({ data, selected }: NodeProps) => {
return (
<>
<HiddenHandles
sourcePosition={Position.Bottom}
sourceStyle={{ top: BADGE_BOTTOM_Y }}
style={{ left: BADGE_CENTER_X }}
targetPosition={Position.Top}
targetStyle={{ top: BADGE_TOP_Y }}
sourcePosition={Position.Right}
sourceStyle={{ left: BADGE_RIGHT_X, top: BADGE_CENTER_Y }}
targetPosition={Position.Left}
targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }}
/>
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
<circle
@@ -12,10 +12,10 @@ interface HiddenHandlesProps {
}
export const HiddenHandles = ({
sourcePosition = Position.Bottom,
sourcePosition = Position.Right,
sourceStyle,
style,
targetPosition = Position.Top,
targetPosition = Position.Left,
targetStyle,
}: HiddenHandlesProps) => (
<>
@@ -32,7 +32,7 @@ const buildNodeProps = (graphNode: GraphNode): NodeProps =>
}) as unknown as NodeProps;
describe("ResourceNode", () => {
it("positions graph handles for vertical top-to-bottom edges", () => {
it("positions graph handles for horizontal left-to-right edges", () => {
// Given
const props = buildNodeProps(buildGraphNode("S3Bucket", "logs"));
@@ -42,8 +42,10 @@ describe("ResourceNode", () => {
// Then
expect(hiddenHandlesMock).toHaveBeenCalledWith(
expect.objectContaining({
sourcePosition: Position.Bottom,
targetPosition: Position.Top,
sourcePosition: Position.Right,
sourceStyle: { left: 90, top: 26 },
targetPosition: Position.Left,
targetStyle: { left: 46, top: 26 },
}),
undefined,
);
@@ -22,8 +22,8 @@ const BADGE_SIZE = 44;
const BADGE_RADIUS = BADGE_SIZE / 2;
const BADGE_CENTER_X = NODE_WIDTH / 2;
const BADGE_CENTER_Y = 26;
const BADGE_BOTTOM_Y = BADGE_CENTER_Y + BADGE_RADIUS;
const BADGE_TOP_Y = BADGE_CENTER_Y - BADGE_RADIUS;
const BADGE_LEFT_X = BADGE_CENTER_X - BADGE_RADIUS;
const BADGE_RIGHT_X = BADGE_CENTER_X + BADGE_RADIUS;
const ICON_SIZE = 28;
const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2;
const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
@@ -63,11 +63,10 @@ export const ResourceNode = ({ data, selected }: NodeProps) => {
return (
<>
<HiddenHandles
sourcePosition={Position.Bottom}
sourceStyle={{ top: BADGE_BOTTOM_Y }}
style={{ left: BADGE_CENTER_X }}
targetPosition={Position.Top}
targetStyle={{ top: BADGE_TOP_Y }}
sourcePosition={Position.Right}
sourceStyle={{ left: BADGE_RIGHT_X, top: BADGE_CENTER_Y }}
targetPosition={Position.Left}
targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }}
/>
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
{glowRadius > 0 && (
@@ -72,7 +72,7 @@ describe("layoutWithDagre", () => {
expect(a).toEqual(b);
});
it("spreads sibling nodes horizontally to use wide graph space", () => {
it("places connected children to the right and stacks siblings within the horizontal rank", () => {
const rootNode: GraphNode = {
id: "root",
labels: ["AWSAccount"],
@@ -106,6 +106,9 @@ describe("layoutWithDagre", () => {
})),
);
const rootPosition = rfNodes.find(
(candidate) => candidate.id === "root",
)?.position;
const siblingPositions = siblingNodes.map((node) => {
const rfNode = rfNodes.find((candidate) => candidate.id === node.id);
@@ -121,10 +124,14 @@ describe("layoutWithDagre", () => {
Math.max(...siblingPositions.map((position) => position.y)) -
Math.min(...siblingPositions.map((position) => position.y));
expect(xSpread).toBeGreaterThan(ySpread);
expect(rootPosition).toBeDefined();
siblingPositions.forEach((position) => {
expect(position.x).toBeGreaterThan(rootPosition?.x ?? 0);
});
expect(ySpread).toBeGreaterThan(xSpread);
});
it("connects edges through top and bottom node sides for vertical layout", () => {
it("connects edges through right and left node sides for horizontal layout", () => {
const { rfNodes } = layoutWithDagre(
[findingNode, resourceNode],
[
@@ -138,8 +145,8 @@ describe("layoutWithDagre", () => {
);
rfNodes.forEach((node) => {
expect(node.sourcePosition).toBe(Position.Bottom);
expect(node.targetPosition).toBe(Position.Top);
expect(node.sourcePosition).toBe(Position.Right);
expect(node.targetPosition).toBe(Position.Left);
});
});
@@ -65,7 +65,7 @@ export const layoutWithDagre = (
): { rfNodes: Node<NodeData>[]; rfEdges: Edge[] } => {
const g = new Graph();
g.setGraph({
rankdir: "TB",
rankdir: "LR",
nodesep: 80,
ranksep: 150,
marginx: 50,
@@ -112,8 +112,8 @@ export const layoutWithDagre = (
x: dagreNode.x - width / 2,
y: dagreNode.y - height / 2,
},
sourcePosition: Position.Bottom,
targetPosition: Position.Top,
sourcePosition: Position.Right,
targetPosition: Position.Left,
data: { graphNode: node },
width,
height,