Skip to content
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,4 @@ storybook-static

.bob/notes
.codex
.claude/
1 change: 1 addition & 0 deletions packages/ui/src/layout/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const Navigation: FunctionComponent<INavigationSidebar> = (props) => {
children: [
{ title: 'Design', to: Links.Home },
{ title: 'Source Code', to: Links.SourceCode },
{ title: 'Topology', to: Links.Topology },
],
},
{
Expand Down
161 changes: 118 additions & 43 deletions packages/ui/src/layout/__snapshots__/Navigation.test.tsx.snap

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions packages/ui/src/pages/Topology/OrthogonalBendpointsEdge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Point } from '@patternfly/react-topology';

import { LayoutType } from '../../components/Visualization/Canvas/canvas.models';
import { OrthogonalBendpointsEdge } from './OrthogonalBendpointsEdge';

const node = (pos: { x: number; y: number }, size: { width: number; height: number }) => ({
getPosition: () => pos,
getDimensions: () => size,
});

const wireEdge = (edge: OrthogonalBendpointsEdge, layout: string, source: unknown, target: unknown) => {
jest.spyOn(edge, 'getSource').mockReturnValue(source as never);
jest.spyOn(edge, 'getTarget').mockReturnValue(target as never);
jest.spyOn(edge, 'getGraph').mockReturnValue({ getLayout: () => layout } as never);
};

describe('OrthogonalBendpointsEdge', () => {
let edge: OrthogonalBendpointsEdge;

beforeEach(() => {
edge = new OrthogonalBendpointsEdge();
});

afterEach(() => {
jest.restoreAllMocks();
});

it('returns no bendpoints for a self-loop', () => {
const self = {};
wireEdge(edge, LayoutType.DagreHorizontal, self, self);
expect(edge.getBendpoints()).toEqual([]);
});

it('returns no bendpoints when source or target is missing', () => {
wireEdge(edge, LayoutType.DagreHorizontal, undefined, node({ x: 0, y: 0 }, { width: 10, height: 10 }));
expect(edge.getBendpoints()).toEqual([]);
});

it('produces a horizontal step (same Y as source) and a vertical step at midX in horizontal layout', () => {
const source = node({ x: 0, y: 0 }, { width: 100, height: 50 });
const target = node({ x: 300, y: 200 }, { width: 100, height: 50 });
wireEdge(edge, LayoutType.DagreHorizontal, source, target);

// Centers: source = (50, 25), target = (350, 225). midX = 200.
expect(edge.getBendpoints()).toEqual([new Point(200, 25), new Point(200, 225)]);
});

it('produces a vertical step (same X as source) and a horizontal step at midY in vertical layout', () => {
const source = node({ x: 0, y: 0 }, { width: 100, height: 50 });
const target = node({ x: 300, y: 200 }, { width: 100, height: 50 });
wireEdge(edge, LayoutType.DagreVertical, source, target);

// Centers: source = (50, 25), target = (350, 225). midY = 125.
expect(edge.getBendpoints()).toEqual([new Point(50, 125), new Point(350, 125)]);
});
});
40 changes: 40 additions & 0 deletions packages/ui/src/pages/Topology/OrthogonalBendpointsEdge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { BaseEdge, Point } from '@patternfly/react-topology';

import { LayoutType } from '../../components/Visualization/Canvas/canvas.models';

/**
* Edge element that bends twice to produce orthogonal (stepped) lines:
* one segment along the layout's primary axis, a perpendicular step in the
* middle, then another segment along the primary axis to the target.
*/
export class OrthogonalBendpointsEdge extends BaseEdge {
getBendpoints(): Point[] {
const source = this.getSource();
const target = this.getTarget();
if (!source || !target || source === target) {
return [];
}

const sourcePos = source.getPosition();
const sourceSize = source.getDimensions();
const targetPos = target.getPosition();
const targetSize = target.getDimensions();

const startX = sourcePos.x + sourceSize.width / 2;
const startY = sourcePos.y + sourceSize.height / 2;
const endX = targetPos.x + targetSize.width / 2;
const endY = targetPos.y + targetSize.height / 2;

// Edges may briefly be queried before they're attached to the graph. Default to
// horizontal so we always return a sensible bend pair even in that window.
const graph = this.getGraph?.();
const isHorizontal = graph?.getLayout?.() !== LayoutType.DagreVertical;

if (isHorizontal) {
const midX = (startX + endX) / 2;
return [new Point(midX, startY), new Point(midX, endY)];
}
const midY = (startY + endY) / 2;
return [new Point(startX, midY), new Point(endX, midY)];
}
}
64 changes: 64 additions & 0 deletions packages/ui/src/pages/Topology/TopologyPage.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
.topology-page {
width: 100%;
height: 100%;
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;

.pf-topology-container {
flex: 1 1 auto;
}

// Stepped edge paths enclose an area, which the browser would fill in black
// without an explicit fill: none.
.custom-edge {
&__background,
&__body {
fill: none;
}

&__body {
stroke-width: 2;
}

&__connector {
stroke-width: 2;
}
}

// Let the node labels blend with the canvas background instead of using the
// default solid background coming from CustomNode.scss.
.custom-node {
&__label {
&__text {
background-color: transparent;
}
}
}
}

.topology-collapsed-route {
cursor: pointer;

&:hover {
filter: brightness(1.05);
}
}

.topology-external-endpoint,
.topology-dynamic-endpoint {
// External and dynamic endpoints are read-only — make the visual cue obvious
// by using a dashed border on the container.
.custom-node {
&__container {
&__image {
border-style: dashed;
}
}
}

&__badge {
right: -12px;
}
}
Loading
Loading