diff --git a/src/components/canvas/canvas.test.tsx b/src/components/canvas/canvas.test.tsx
index feab3c0..7337483 100644
--- a/src/components/canvas/canvas.test.tsx
+++ b/src/components/canvas/canvas.test.tsx
@@ -1,10 +1,13 @@
import { Canvas } from '@/components/canvas/canvas';
import { render, screen } from '@/mocks/testing-utils';
import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes';
+import { ORDERS_TO_EMPLOYEES_EDGE } from '@/mocks/datasets/edges';
describe('canvas', () => {
it('Should have elements on the canvas', () => {
- render();
+ render(
+ ,
+ );
expect(screen.getByRole('button', { name: /Plus/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Minus/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Full Screen/ })).toBeInTheDocument();
diff --git a/src/components/canvas/canvas.tsx b/src/components/canvas/canvas.tsx
index 0c0ba28..caafb67 100644
--- a/src/components/canvas/canvas.tsx
+++ b/src/components/canvas/canvas.tsx
@@ -1,14 +1,14 @@
import '@xyflow/react/dist/style.css';
import styled from '@emotion/styled';
-import { useEffect } from 'react';
-import { ReactFlow, Background, ProOptions, ReactFlowProps, useNodesState } from '@xyflow/react';
+import { Background, ProOptions, ReactFlow, ReactFlowProps, useNodesState } from '@xyflow/react';
import { MiniMap } from '@/components/controls/mini-map';
import { Controls } from '@/components/controls/controls';
-import { Node as ExternalNode } from '@/types';
+import { Edge, Node as ExternalNode } from '@/types';
import { Node } from '@/components/node/node';
import { useCanvas } from '@/components/canvas/use-canvas';
import { InternalNode } from '@/types/internal';
+import { FloatingEdge } from '@/components/edge/floating-edge';
const MAX_ZOOM = 3;
const MIN_ZOOM = 0.1;
@@ -28,17 +28,16 @@ const nodeTypes = {
connectable: Node,
};
-type Props = Pick & { nodes: ExternalNode[] };
+const edgeTypes = {
+ floatingEdge: FloatingEdge,
+};
-export const Canvas = ({ title, nodes: externalNodes }: Props) => {
- const { initialNodes } = useCanvas(externalNodes);
+type Props = Pick & { nodes: ExternalNode[]; edges: Edge[] };
- const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
+export const Canvas = ({ title, nodes: externalNodes, edges }: Props) => {
+ const { initialNodes } = useCanvas(externalNodes);
- useEffect(() => {
- setNodes(initialNodes);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [initialNodes]);
+ const [nodes, , onNodesChange] = useNodesState(initialNodes);
return (
@@ -48,7 +47,10 @@ export const Canvas = ({ title, nodes: externalNodes }: Props) => {
maxZoom={MAX_ZOOM}
minZoom={MIN_ZOOM}
nodeTypes={nodeTypes}
+ edgeTypes={edgeTypes}
nodes={nodes}
+ onlyRenderVisibleElements={true}
+ edges={edges}
onNodesChange={onNodesChange}
>
diff --git a/src/components/canvas/use-canvas.test.tsx b/src/components/canvas/use-canvas.test.tsx
index 4ab6454..a6b4b4f 100644
--- a/src/components/canvas/use-canvas.test.tsx
+++ b/src/components/canvas/use-canvas.test.tsx
@@ -14,6 +14,10 @@ describe('use-canvas', () => {
x: 100,
y: 100,
},
+ measured: {
+ height: 36,
+ width: 244,
+ },
data: {
borderVariant: undefined,
fields: [
@@ -30,6 +34,10 @@ describe('use-canvas', () => {
x: 300,
y: 300,
},
+ measured: {
+ height: 72,
+ width: 244,
+ },
data: {
borderVariant: undefined,
fields: [
diff --git a/src/components/diagram.stories.tsx b/src/components/diagram.stories.tsx
index f8947af..38a9840 100644
--- a/src/components/diagram.stories.tsx
+++ b/src/components/diagram.stories.tsx
@@ -2,6 +2,7 @@ import { Meta, StoryObj } from '@storybook/react';
import { Diagram } from '@/components/diagram';
import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes';
+import { ORDERS_TO_EMPLOYEES_EDGE } from '@/mocks/datasets/edges';
const diagram: Meta = {
title: 'Diagram',
@@ -9,6 +10,7 @@ const diagram: Meta = {
args: {
title: 'MongoDB Diagram',
isDarkMode: true,
+ edges: [ORDERS_TO_EMPLOYEES_EDGE],
nodes: [ORDERS_NODE, EMPLOYEES_NODE],
},
};
diff --git a/src/components/edge/floating-edge.test.tsx b/src/components/edge/floating-edge.test.tsx
new file mode 100644
index 0000000..b68fd46
--- /dev/null
+++ b/src/components/edge/floating-edge.test.tsx
@@ -0,0 +1,62 @@
+import { Position, useNodes } from '@xyflow/react';
+import { ComponentProps } from 'react';
+
+import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes';
+import { render, screen } from '@/mocks/testing-utils';
+import { FloatingEdge } from '@/components/edge/floating-edge';
+
+vi.mock('@xyflow/react', async () => {
+ const actual = await vi.importActual('@xyflow/react');
+ return {
+ ...actual,
+ useNodes: vi.fn(),
+ };
+});
+
+describe('floating-edge', () => {
+ beforeEach(() => {
+ const nodes = [
+ { ...ORDERS_NODE, data: { title: ORDERS_NODE.title, fields: ORDERS_NODE.fields } },
+ { ...EMPLOYEES_NODE, data: { title: EMPLOYEES_NODE.title, fields: EMPLOYEES_NODE.fields } },
+ ];
+ const mockedNodes = vi.mocked(useNodes);
+ mockedNodes.mockReturnValue(nodes);
+ });
+
+ const renderComponent = (props?: Partial>) => {
+ return render(
+ ,
+ );
+ };
+
+ it('Should render edge', () => {
+ renderComponent();
+ const path = screen.getByTestId('floating-edge-orders-to-employees');
+ expect(path).toHaveAttribute('id', 'orders-to-employees');
+ expect(path).toHaveAttribute(
+ 'd',
+ 'M240 136L240 156L 240,213Q 240,218 245,218L 381,218Q 386,218 386,223L386 280L386 300',
+ );
+ });
+
+ it('Should not render edge if source does not exist', () => {
+ renderComponent({ source: 'unknown' });
+ expect(screen.queryByTestId('floating-edge-orders-to-employees')).not.toBeInTheDocument();
+ });
+
+ it('Should not render edge if target does not exist', () => {
+ renderComponent({ target: 'unknown' });
+ expect(screen.queryByTestId('floating-edge-orders-to-employees')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/edge/floating-edge.tsx b/src/components/edge/floating-edge.tsx
new file mode 100644
index 0000000..5e5b260
--- /dev/null
+++ b/src/components/edge/floating-edge.tsx
@@ -0,0 +1,30 @@
+import { EdgeProps, getSmoothStepPath, useNodes } from '@xyflow/react';
+import { useMemo } from 'react';
+
+import { getEdgeParams } from '@/utilities/get-edge-params';
+import { InternalNode } from '@/types/internal';
+
+export const FloatingEdge = ({ id, source, target }: EdgeProps) => {
+ const nodes = useNodes();
+
+ const { sourceNode, targetNode } = useMemo(() => {
+ const sourceNode = nodes.find(n => n.id === source);
+ const targetNode = nodes.find(n => n.id === target);
+ return { sourceNode, targetNode };
+ }, [nodes, source, target]);
+
+ if (!sourceNode || !targetNode) return null;
+
+ const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(sourceNode, targetNode);
+
+ const [path] = getSmoothStepPath({
+ sourceX: sx,
+ sourceY: sy,
+ sourcePosition: sourcePos,
+ targetPosition: targetPos,
+ targetX: tx,
+ targetY: ty,
+ });
+
+ return ;
+};
diff --git a/src/components/node/node.tsx b/src/components/node/node.tsx
index 05b3090..0c5af4c 100644
--- a/src/components/node/node.tsx
+++ b/src/components/node/node.tsx
@@ -1,4 +1,4 @@
-import { NodeProps, useViewport } from '@xyflow/react';
+import { Handle, NodeProps, Position, useViewport } from '@xyflow/react';
import styled from '@emotion/styled';
import { fontFamilies, spacing } from '@leafygreen-ui/tokens';
import { useTheme } from '@emotion/react';
@@ -76,6 +76,10 @@ const NodeHeaderTitle = styled.div`
${ellipsisTruncation}
`;
+const NodeHandle = styled(Handle)`
+ visibility: hidden;
+`;
+
export const Node = ({ type, data: { title, fields, borderVariant } }: NodeProps) => {
const theme = useTheme();
const { zoom } = useViewport();
@@ -91,6 +95,8 @@ export const Node = ({ type, data: { title, fields, borderVariant } }: NodeProps
return (
+
+
{!isContextualZoom && (
diff --git a/src/mocks/datasets/edges.ts b/src/mocks/datasets/edges.ts
new file mode 100644
index 0000000..3af7794
--- /dev/null
+++ b/src/mocks/datasets/edges.ts
@@ -0,0 +1,10 @@
+import { Edge } from '@/types';
+
+export const ORDERS_TO_EMPLOYEES_EDGE: Edge = {
+ id: 'employees-to-orders',
+ type: 'floatingEdge',
+ source: 'employees',
+ target: 'orders',
+ markerEnd: 'one',
+ markerStart: 'one',
+};
diff --git a/src/mocks/datasets/nodes.tsx b/src/mocks/datasets/nodes.tsx
index 770af32..93edeaf 100644
--- a/src/mocks/datasets/nodes.tsx
+++ b/src/mocks/datasets/nodes.tsx
@@ -1,4 +1,5 @@
-import { Node } from '@/types/node';
+import { Node } from '@/types';
+import { DEFAULT_FIELD_HEIGHT, DEFAULT_NODE_WIDTH } from '@/utilities/constants';
export const ORDERS_NODE: Node = {
id: 'orders',
@@ -7,6 +8,10 @@ export const ORDERS_NODE: Node = {
x: 100,
y: 100,
},
+ measured: {
+ width: DEFAULT_NODE_WIDTH,
+ height: DEFAULT_FIELD_HEIGHT * 2,
+ },
title: 'orders',
fields: [
{ name: 'ORDER_ID', type: 'varchar', glyphs: ['key'] },
@@ -21,6 +26,10 @@ export const EMPLOYEES_NODE: Node = {
x: 300,
y: 300,
},
+ measured: {
+ width: DEFAULT_NODE_WIDTH,
+ height: DEFAULT_FIELD_HEIGHT * 4,
+ },
title: 'employees',
fields: [
{ name: 'employeeId', type: 'objectId', glyphs: ['key'] },
diff --git a/src/utilities/get-edge-params.test.ts b/src/utilities/get-edge-params.test.ts
new file mode 100644
index 0000000..97e2b7a
--- /dev/null
+++ b/src/utilities/get-edge-params.test.ts
@@ -0,0 +1,19 @@
+import { getEdgeParams } from '@/utilities/get-edge-params';
+import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes';
+
+describe('get-edge-params', () => {
+ it('Should get parameters', () => {
+ const result = getEdgeParams(
+ { ...ORDERS_NODE, data: { title: ORDERS_NODE.title, fields: ORDERS_NODE.fields } },
+ { ...EMPLOYEES_NODE, data: { title: EMPLOYEES_NODE.title, fields: EMPLOYEES_NODE.fields } },
+ );
+ expect(result).toEqual({
+ sourcePos: 'bottom',
+ sx: 240,
+ sy: 136,
+ targetPos: 'top',
+ tx: 386,
+ ty: 300,
+ });
+ });
+});
diff --git a/src/utilities/get-edge-params.ts b/src/utilities/get-edge-params.ts
new file mode 100644
index 0000000..1e2f1b9
--- /dev/null
+++ b/src/utilities/get-edge-params.ts
@@ -0,0 +1,92 @@
+import { Position, XYPosition } from '@xyflow/react';
+
+import { InternalNode } from '@/types/internal';
+
+/**
+ * Returns the coordinates where a line connecting the centers of the source and target nodes intersects
+ * the edges of those nodes. This implementation is copied from:
+ * https://github.com/xyflow/xyflow/blob/main/examples/react/src/examples/FloatingEdges/utils.ts
+ *
+ * @param intersectionNode The source node
+ * @param targetNode The target node
+ */
+const getNodeIntersection = (intersectionNode: InternalNode, targetNode: InternalNode): XYPosition => {
+ const { width: intersectionNodeWidth, height: intersectionNodeHeight } = intersectionNode.measured ?? {
+ width: 0,
+ height: 0,
+ };
+ const targetPosition = targetNode.position;
+
+ const w = (intersectionNodeWidth ?? 0) / 2;
+ const h = (intersectionNodeHeight ?? 0) / 2;
+
+ const x2 = intersectionNode.position.x + w;
+ const y2 = intersectionNode.position.y + h;
+ const x1 = targetPosition.x + w;
+ const y1 = targetPosition.y + h;
+
+ const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
+ const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
+ const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
+ const xx3 = a * xx1;
+ const yy3 = a * yy1;
+ const x = w * (xx3 + yy3) + x2;
+ const y = h * (-xx3 + yy3) + y2;
+
+ return { x, y };
+};
+
+/**
+ * Normalises the edge position based on co-ordinates of the edge
+ * @param node The node where the edge is pointing from. This implementation is copied from:
+ * https://github.com/xyflow/xyflow/blob/main/examples/react/src/examples/FloatingEdges/utils.ts
+ *
+ * @param intersectionPoint The position of the edge
+ */
+const getEdgePosition = (node: InternalNode, intersectionPoint: XYPosition) => {
+ const n = { ...node.position, ...node };
+ const nx = Math.round(n.x);
+ const ny = Math.round(n.y);
+ const px = Math.round(intersectionPoint.x);
+ const py = Math.round(intersectionPoint.y);
+
+ if (px <= nx + 1) {
+ return Position.Left;
+ }
+ if (px >= nx + (n.measured?.width ?? 0) - 1) {
+ return Position.Right;
+ }
+ if (py <= ny + 1) {
+ return Position.Top;
+ }
+ if (py >= n.y + (n.measured?.height ?? 0) - 1) {
+ return Position.Bottom;
+ }
+
+ return Position.Top;
+};
+
+/**
+ * Returns the coordinates where a line connecting the centers of the source and target nodes intersects
+ * the edges of those nodes. This implementation is copied from:
+ * https://github.com/xyflow/xyflow/blob/main/examples/react/src/examples/FloatingEdges/utils.ts
+ *
+ * @param source The source node
+ * @param target The target node
+ */
+export const getEdgeParams = (source: InternalNode, target: InternalNode) => {
+ const sourceIntersectionPoint = getNodeIntersection(source, target);
+ const targetIntersectionPoint = getNodeIntersection(target, source);
+
+ const sourcePos = getEdgePosition(source, sourceIntersectionPoint);
+ const targetPos = getEdgePosition(target, targetIntersectionPoint);
+
+ return {
+ sx: sourceIntersectionPoint.x,
+ sy: sourceIntersectionPoint.y,
+ tx: targetIntersectionPoint.x,
+ ty: targetIntersectionPoint.y,
+ sourcePos,
+ targetPos,
+ };
+};
diff --git a/tsconfig.json b/tsconfig.json
index 67d2c46..335b62f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,10 +30,10 @@
},
"include": [
"src",
+ ".storybook"
],
"exclude": [
"node_modules",
"src/**/*.d.ts",
- ".storybook"
]
}