From 5ddd5d3f35b953d2f45df8d7503d33a3f17d2f9e Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Wed, 12 Feb 2025 23:28:04 +0100 Subject: [PATCH] feat: floating edge --- .../investigations/floating-connection.tsx | 64 +++++++++++ .../investigations/floating-edge.tsx | 60 +++++++++++ src/components/investigations/graph.tsx | 5 +- src/components/investigations/nodes/email.tsx | 2 +- .../investigations/nodes/individual.tsx | 5 +- .../investigations/nodes/ip_address.tsx | 2 +- src/components/investigations/nodes/phone.tsx | 2 +- .../investigations/nodes/physical_address.tsx | 2 +- .../investigations/nodes/social.tsx | 2 +- src/lib/utils.ts | 101 ++++++++++++++++++ 10 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 src/components/investigations/floating-connection.tsx create mode 100644 src/components/investigations/floating-edge.tsx diff --git a/src/components/investigations/floating-connection.tsx b/src/components/investigations/floating-connection.tsx new file mode 100644 index 0000000..bc5b1ab --- /dev/null +++ b/src/components/investigations/floating-connection.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { getBezierPath, Position } from '@xyflow/react'; +import { getEdgeParams } from '@/src/lib/utils'; + +const FloatingConnectionLine = ({ + toX, + toY, + fromPosition, + toPosition, + fromNode, +}: { + toX: number, + toY: number, + fromPosition: Position, + toPosition: Position, + fromNode: number, +}): any => { + if (!fromNode) { + return null; + } + + const targetNode = { + id: 'connection-target', + measured: { + width: 1, + height: 1, + }, + internals: { + positionAbsolute: { x: toX, y: toY }, + }, + }; + + const { sx, sy } = getEdgeParams(fromNode, targetNode); + const [edgePath] = getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: fromPosition, + targetPosition: toPosition, + targetX: toX, + targetY: toY, + }); + + return ( + + + + + ); +} + +export default FloatingConnectionLine; \ No newline at end of file diff --git a/src/components/investigations/floating-edge.tsx b/src/components/investigations/floating-edge.tsx new file mode 100644 index 0000000..e879860 --- /dev/null +++ b/src/components/investigations/floating-edge.tsx @@ -0,0 +1,60 @@ +import { getEdgeParams } from '@/src/lib/utils'; +import { + EdgeLabelRenderer, + getBezierPath, + useInternalNode +} from '@xyflow/react'; +import { useInvestigationContext } from '../contexts/investigation-provider'; +import { Badge } from '@radix-ui/themes'; +function FloatingEdge(props: any) { + const { id, source, target, markerEnd, label, confidence_level, sourceX, sourceY, targetX, targetY, style } = props + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + const { settings } = useInvestigationContext() + + if (!sourceNode || !targetNode) { + return null; + } + + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + sourceNode, + targetNode, + ); + + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetPosition: targetPos, + targetX: tx, + targetY: ty, + }); + + return ( + <> + + + {settings.showEdgeLabel && + {label} {confidence_level && + <>{confidence_level}%} + } + + + ); +} + +export default FloatingEdge; + + diff --git a/src/components/investigations/graph.tsx b/src/components/investigations/graph.tsx index 37f6252..01d9875 100644 --- a/src/components/investigations/graph.tsx +++ b/src/components/investigations/graph.tsx @@ -33,10 +33,12 @@ import { IconButton, Tooltip, Spinner, Card, Flex, SegmentedControl } from '@rad import { isNode, isEdge, getIncomers, getOutgoers } from "@xyflow/react"; import { EdgeBase } from '@xyflow/system'; import { useInvestigationContext } from '../contexts/investigation-provider'; +import FloatingEdge from './floating-edge'; +import FloatingConnectionLine from './floating-connection'; const nodeTypes = { individual: IndividualNode, phone: PhoneNode, ip: IpNode, email: EmailNode, social: SocialNode, address: AddressNode }; const edgeTypes = { - 'custom': CustomEdge, + 'custom': FloatingEdge, }; const getLayoutedElements = (nodes: any[], edges: any[], options: { direction: any; }) => { const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); @@ -271,6 +273,7 @@ const LayoutFlow = ({ initialNodes, initialEdges, theme }: { initialNodes: any, onNodeClick={onNodeClick} onPaneClick={onPaneClick} minZoom={0.1} + connectionLineComponent={FloatingConnectionLine as any} // edgesUpdatable={!isLocked} // edgesFocusable={!isLocked} // nodesDraggable={!isLocked} diff --git a/src/components/investigations/nodes/email.tsx b/src/components/investigations/nodes/email.tsx index 97ad716..d34ff1c 100644 --- a/src/components/investigations/nodes/email.tsx +++ b/src/components/investigations/nodes/email.tsx @@ -50,7 +50,7 @@ function EmailNode({ data }: any) { diff --git a/src/components/investigations/nodes/individual.tsx b/src/components/investigations/nodes/individual.tsx index 3264a0b..8a36dfd 100644 --- a/src/components/investigations/nodes/individual.tsx +++ b/src/components/investigations/nodes/individual.tsx @@ -65,15 +65,16 @@ function Custom(props: any) { /> } + diff --git a/src/components/investigations/nodes/ip_address.tsx b/src/components/investigations/nodes/ip_address.tsx index 76520b5..6b57c50 100644 --- a/src/components/investigations/nodes/ip_address.tsx +++ b/src/components/investigations/nodes/ip_address.tsx @@ -51,7 +51,7 @@ function Custom({ data }: any) { diff --git a/src/components/investigations/nodes/phone.tsx b/src/components/investigations/nodes/phone.tsx index 59b394d..0d4f737 100644 --- a/src/components/investigations/nodes/phone.tsx +++ b/src/components/investigations/nodes/phone.tsx @@ -51,7 +51,7 @@ function Custom({ data }: any) { diff --git a/src/components/investigations/nodes/physical_address.tsx b/src/components/investigations/nodes/physical_address.tsx index 66e69d2..e5c9e72 100644 --- a/src/components/investigations/nodes/physical_address.tsx +++ b/src/components/investigations/nodes/physical_address.tsx @@ -50,7 +50,7 @@ function AddressNode({ data }: any) { diff --git a/src/components/investigations/nodes/social.tsx b/src/components/investigations/nodes/social.tsx index 6d9a023..5d87abe 100644 --- a/src/components/investigations/nodes/social.tsx +++ b/src/components/investigations/nodes/social.tsx @@ -59,7 +59,7 @@ function Custom({ data }: any) { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d72b3ea..ca96204 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -6,3 +6,104 @@ export function cn(...inputs: ClassValue[]) { } export const zoomSelector = (s: { transform: number[]; }) => s.transform[2] >= 0.6; + +import { Position, MarkerType } from '@xyflow/react'; + +// this helper function returns the intersection point +// of the line between the center of the intersectionNode and the target node +function getNodeIntersection(intersectionNode: { measured: { width: any; height: any; }; internals: { positionAbsolute: any; }; }, targetNode: { internals: { positionAbsolute: any; }; measured: { width: number; height: number; }; }) { + // https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a + const { width: intersectionNodeWidth, height: intersectionNodeHeight } = + intersectionNode.measured; + const intersectionNodePosition = intersectionNode.internals.positionAbsolute; + const targetPosition = targetNode.internals.positionAbsolute; + + const w = intersectionNodeWidth / 2; + const h = intersectionNodeHeight / 2; + + const x2 = intersectionNodePosition.x + w; + const y2 = intersectionNodePosition.y + h; + const x1 = targetPosition.x + targetNode.measured.width / 2; + const y1 = targetPosition.y + targetNode.measured.height / 2; + + 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 }; +} + +// returns the position (top,right,bottom or right) passed node compared to the intersection point +function getEdgePosition(node: { internals: { positionAbsolute: any; }; }, intersectionPoint: { x: any; y: any; }) { + const n = { ...node.internals.positionAbsolute, ...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 - 1) { + return Position.Right; + } + if (py <= ny + 1) { + return Position.Top; + } + if (py >= n.y + n.measured.height - 1) { + return Position.Bottom; + } + + return Position.Top; +} + +// returns the parameters (sx, sy, tx, ty, sourcePos, targetPos) you need to create an edge +export function getEdgeParams(source: any, target: any) { + 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, + }; +} + +export function initialElements() { + const nodes = []; + const edges = []; + const center = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + + nodes.push({ id: 'target', data: { label: 'Target' }, position: center }); + + for (let i = 0; i < 8; i++) { + const degrees = i * (360 / 8); + const radians = degrees * (Math.PI / 180); + const x = 250 * Math.cos(radians) + center.x; + const y = 250 * Math.sin(radians) + center.y; + + nodes.push({ id: `${i}`, data: { label: 'Source' }, position: { x, y } }); + + edges.push({ + id: `edge-${i}`, + target: 'target', + source: `${i}`, + type: 'floating', + markerEnd: { + type: MarkerType.Arrow, + }, + }); + } + + return { nodes, edges }; +}