From 2ad4eea017872e1591d91c92a12e5263e401e34c Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Mon, 17 Mar 2025 17:13:35 +0100 Subject: [PATCH] feat: node performance enhancement --- .../src/components/contexts/node-context.tsx | 153 +++++-- .../src/components/investigations/graph.tsx | 427 ++++++++++++------ .../components/investigations/nodes/email.tsx | 204 ++------- .../investigations/nodes/ip_address.tsx | 176 ++------ .../nodes/minimal_individual.tsx | 156 ------- .../investigations/nodes/person.tsx | 40 ++ .../components/investigations/nodes/phone.tsx | 165 ++----- .../investigations/nodes/physical_address.tsx | 121 +---- .../investigations/nodes/social.tsx | 192 ++------ .../investigations/simple-floating-edge.tsx | 44 ++ flowsint-web/src/lib/utils.ts | 101 ++++- flowsint-web/src/store/flow-store.ts | 25 +- flowsint-web/styles/globals.css | 14 +- 13 files changed, 758 insertions(+), 1060 deletions(-) delete mode 100644 flowsint-web/src/components/investigations/nodes/minimal_individual.tsx create mode 100644 flowsint-web/src/components/investigations/nodes/person.tsx create mode 100644 flowsint-web/src/components/investigations/simple-floating-edge.tsx diff --git a/flowsint-web/src/components/contexts/node-context.tsx b/flowsint-web/src/components/contexts/node-context.tsx index 44a3759..9eedbf9 100644 --- a/flowsint-web/src/components/contexts/node-context.tsx +++ b/flowsint-web/src/components/contexts/node-context.tsx @@ -16,6 +16,8 @@ import { useConfirm } from "@/components/use-confirm-dialog"; import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; import { AlertCircle } from "lucide-react"; import { NodeNotesEditor } from "../node-notes-editor"; +import { useFlowStore } from "@/store/flow-store"; +import { toast } from "sonner"; interface NodeContextType { setOpenAddNodeModal: any, @@ -55,6 +57,7 @@ export const NodeProvider: React.FC = (props: any) => { const [loading, setLoading] = useState(false) const [nodeType, setnodeType] = useState(null) const nodeId = useNodeId(); + const { setCurrentNode } = useFlowStore() const { confirm } = useConfirm(); const returnError = (message: string) => { @@ -79,53 +82,113 @@ export const NodeProvider: React.FC = (props: any) => { await handleAddNode(data); }; - const handleAddNode = async (data: any) => { - setLoading(true) - if (!nodeId) return returnError("No node detected.") - const dataToInsert = { ...data, investigation_id } - if (nodeType.table !== "individuals") - dataToInsert["individual_id"] = nodeId - const node = await supabase.from(nodeType.table).insert(dataToInsert).select("*") - .single() - .then(({ data, error }) => { - if (error) - returnError(error.details) - return data - }) - if (!node) return - if (nodeType.table === "individuals") { - // create relation to investigation - await supabase.from("investigation_individuals").insert({ - individual_id: node.id, - investigation_id: investigation_id - }).then(({ error }) => console.log(error)) + const handleAddNode = async ( + data: any, + ) => { + try { + setLoading(true) - await supabase.from("relationships").upsert({ - individual_a: nodeId, - individual_b: node.id, - relation_type: "relation" - }).then(({ error }) => { if (error) returnError(error.details) } - ) + // Validate required data + if (!nodeId) { + toast.error("No node detected.") + setLoading(false) + return + } + + // Prepare data for insertion + const dataToInsert = { ...data, investigation_id } + if (nodeType.table !== "individuals") { + dataToInsert["individual_id"] = nodeId + } + + // Insert the node + const { data: nodeData, error: insertError } = await supabase + .from(nodeType.table) + .insert(dataToInsert) + .select("*") + .single() + + if (insertError) { + toast.error(insertError.details) + setLoading(false) + return + } + + if (!nodeData) { + toast.error("Failed to create node.") + setLoading(false) + return + } + + // Handle individuals table specific logic + if (nodeType.table === "individuals") { + // Create relation to investigation + const { error: relationError } = await supabase.from("investigation_individuals").insert({ + individual_id: nodeData.id, + investigation_id: investigation_id, + }) + + if (relationError) { + toast.error("Error creating investigation relation:" + JSON.stringify(relationError)) + } + + // Create relationship between individuals + const { error: relationshipError } = await supabase.from("relationships").upsert({ + individual_a: nodeId, + individual_b: nodeData.id, + relation_type: "relation", + }) + + if (relationshipError) { + toast.error(relationshipError.details) + } + } + + // Add node to graph + const newNode = { + id: nodeData.id, + type: nodeType.type, + data: { ...nodeData, label: data[nodeType.fields[0]] }, + position: { x: 0, y: 0 }, + } + + addNodes(newNode) + + // Add edge if needed + if (nodeId) { + const newEdge = { + source: nodeId, + target: nodeData.id, + type: "custom", + id: `${nodeId}-${nodeData.id}`.toString(), + label: nodeType.type === "individual" ? "relation" : nodeType.type, + } + + addEdges(newEdge) + } + + // Use a callback function with setState to ensure we're working with the latest state + // This is the fix for the setCurrentNode issue + const newNodeId = nodeData.id + + // Close modal and update state in a specific order + setOpenNodeModal(false) + setError(null) + + // Use setTimeout to ensure this happens after the current execution context + // This helps with React's batching of state updates + setTimeout(() => { + setCurrentNode(newNodeId) + setLoading(false) + }, 0) + } catch (error) { + toast.error("An unexpected error occurred") + setLoading(false) } - addNodes({ - id: node.id, - type: nodeType.type, - data: { ...node, label: data[nodeType.fields[0]] }, - position: { x: 0, y: 0 } - }); - if (nodeId) - addEdges({ - source: nodeId, - target: node.id, - type: 'custom', - id: `${nodeId}-${node.id}`.toString(), - label: nodeType.type === "individual" ? "relation" : nodeType.type, - }); - setLoading(false) - setError(null) - setOpenNodeModal(false) } + + const handleDuplicateNode = async () => { await supabase.from("individuals") .select("*") @@ -137,7 +200,7 @@ export const NodeProvider: React.FC = (props: any) => { .insert({ full_name: data.full_name }) .select("*") .single() - if (insertError) returnError(insertError.details) + if (insertError) toast.error(insertError.details) addNodes({ id: node.id, type: "individual", @@ -154,7 +217,7 @@ export const NodeProvider: React.FC = (props: any) => { .delete() .eq("id", nodeId) .then(({ error }) => { - if (error) returnError(error.details) + if (error) toast.error(error.details) }) setNodes((nodes: any[]) => nodes.filter((node: { id: any; }) => node.id !== nodeId.toString())); setEdges((edges: any[]) => edges.filter((edge: { source: any; }) => edge.source !== nodeId.toString())); diff --git a/flowsint-web/src/components/investigations/graph.tsx b/flowsint-web/src/components/investigations/graph.tsx index 065b888..938f15c 100644 --- a/flowsint-web/src/components/investigations/graph.tsx +++ b/flowsint-web/src/components/investigations/graph.tsx @@ -1,5 +1,6 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useState } from "react" +import type React from "react" import { ReactFlow, ReactFlowProvider, @@ -10,9 +11,10 @@ import { type ColorMode, // @ts-ignore MiniMap, + type NodeMouseHandler, } from "@xyflow/react" import "@xyflow/react/dist/style.css" -import IndividualNode from "./nodes/individual" +import IndividualNode from "./nodes/person" import PhoneNode from "./nodes/phone" import IpNode from "./nodes/ip_address" import EmailNode from "./nodes/email" @@ -27,10 +29,13 @@ import { PlusIcon, GroupIcon, WorkflowIcon, + Trash2Icon, + CopyIcon, + EditIcon, + LinkIcon, } from "lucide-react" import { useTheme } from "next-themes" import NewActions from "./new-actions" -import FloatingEdge from "./floating-edge" import FloatingConnectionLine from "./floating-connection" import { useParams } from "next/navigation" import { Tooltip, TooltipContent } from "@/components/ui/tooltip" @@ -46,12 +51,12 @@ import AddNodeModal from "./add-node-modal" import { Dialog, DialogTrigger } from "../ui/dialog" import { memo } from "react" import { shallow } from "zustand/shallow" -import CustomEdge from "./custom-edge" -import floatingEdge from "./floating-edge" +import FloatingEdge from "./simple-floating-edge" +import { TooltipProvider } from "@/components/ui/tooltip" // Define constants outside component to prevent recreation const edgeTypes = { - custom: floatingEdge + custom: FloatingEdge, } const nodeTypes = { @@ -69,7 +74,14 @@ const nodeEdgeSelector = (store: { nodes: any; edges: any }) => ({ edges: store.edges, }) -const actionsSelector = (store: { onNodesChange: any; onEdgesChange: any; onConnect: any; onNodeClick: any; onPaneClick: any; onLayout: any }) => ({ +const actionsSelector = (store: { + onNodesChange: any + onEdgesChange: any + onConnect: any + onNodeClick: any + onPaneClick: any + onLayout: any +}) => ({ onNodesChange: store.onNodesChange, onEdgesChange: store.onEdgesChange, onConnect: store.onConnect, @@ -84,103 +96,188 @@ const stateSelector = (store: { currentNode: any; resetNodeStyles: any; reloadin reloading: store.reloading, }) -// FlowControls component to reduce LayoutFlow complexity -interface FlowControlsProps { - onLayout: (direction: string, fitView: () => void) => void; - fitView: () => void; - handleRefetch: () => void; - reloading: boolean; - setView: (view: string) => void; - zoomIn: () => void; - zoomOut: () => void; - addNodes: (payload: Node | Node[]) => void; +// Node Context Menu component +interface NodeContextMenuProps { + x: number + y: number + nodeId: string | null + nodeType: string | null + onClose: () => void } -const FlowControls = memo(({ onLayout, fitView, handleRefetch, reloading, setView, zoomIn, zoomOut, addNodes }: FlowControlsProps) => { +const NodeContextMenu = memo(({ x, y, nodeId, nodeType, onClose }: NodeContextMenuProps) => { + if (!nodeId) return null + + const handleEditNode = () => { + console.log(`Edit node ${nodeId} of type ${nodeType}`) + onClose() + } + + const handleDeleteNode = () => { + console.log(`Delete node ${nodeId}`) + onClose() + } + + const handleDuplicateNode = () => { + console.log(`Duplicate node ${nodeId}`) + onClose() + } + + const handleCreateConnection = () => { + console.log(`Create connection from node ${nodeId}`) + onClose() + } + return ( - <> - - - - - - Auto layout - - - - - - View 2D Graph - - - -
-
- - -
-
-
- - - - - - Center view - - - - - - Zoom in - - - - - - Zoom out - - - +
+
+ {nodeType?.toUpperCase()} NODE: {nodeId.slice(0, 8)} +
+
+ + + + +
+
) -}); +}) + +interface FlowControlsProps { + onLayout: (direction: string, fitView: () => void) => void + fitView: () => void + handleRefetch: () => void + reloading: boolean + setView: (view: string) => void + zoomIn: () => void + zoomOut: () => void + addNodes: (payload: Node | Node[]) => void +} + +const FlowControls = memo( + ({ onLayout, fitView, handleRefetch, reloading, setView, zoomIn, zoomOut, addNodes }: FlowControlsProps) => { + return ( + <> + + + + + + Auto layout + + + + + + View 2D Graph + + + +
+
+ + +
+
+
+ + + + + + Center view + + + + + + Zoom in + + + + + + Zoom out + + + + ) + }, +) interface LayoutFlowProps { - refetch: () => void; - theme: string; + refetch: () => void + theme: string } const LayoutFlow = ({ refetch, theme }: LayoutFlowProps) => { - const { fitView, zoomIn, zoomOut, addNodes, getNode, setCenter } = useReactFlow() + const { fitView, zoomIn, zoomOut, addNodes, getNode, setCenter, getNodes } = useReactFlow() const { investigation_id } = useParams() const { settings } = useInvestigationStore() const [_, setView] = useQueryState("view", { defaultValue: "flow-graph" }) + // Node context menu state + const [nodeContextMenu, setNodeContextMenu] = useState<{ + x: number + y: number + nodeId: string | null + nodeType: string | null + } | null>(null) + // Split store access to minimize re-renders const { nodes, edges } = useFlowStore(nodeEdgeSelector, shallow) - const { onNodesChange, onEdgesChange, onConnect, onNodeClick, onPaneClick, onLayout } = - useFlowStore(actionsSelector, shallow) + const { onNodesChange, onEdgesChange, onConnect, onNodeClick, onPaneClick, onLayout } = useFlowStore( + actionsSelector, + shallow, + ) const { currentNode, resetNodeStyles, reloading } = useFlowStore(stateSelector, shallow) // Initial layout @@ -204,7 +301,7 @@ const LayoutFlow = ({ refetch, theme }: LayoutFlowProps) => { // Node highlighting effect useEffect(() => { resetNodeStyles() - if (!currentNode) return; + if (!currentNode) return const internalNode = getNode(currentNode) if (!internalNode) return @@ -228,71 +325,108 @@ const LayoutFlow = ({ refetch, theme }: LayoutFlowProps) => { }, [currentNode, getNode, setCenter, resetNodeStyles]) // Memoize connection handler to prevent recreation - const handleConnect = useCallback( - (params: any) => onConnect(params, investigation_id), - [onConnect, investigation_id] + const handleConnect = useCallback((params: any) => onConnect(params, investigation_id), [onConnect, investigation_id]) + + // Handle node context menu + const handleNodeContextMenu: NodeMouseHandler = useCallback((event, node) => { + // Prevent default context menu + event.preventDefault() + setNodeContextMenu({ + x: event.clientX - 300, + y: event.clientY - 40, + nodeId: node.id, + nodeType: node.type || "default", + }) + }, []) + + // Close node context menu + const closeNodeContextMenu = useCallback(() => { + setNodeContextMenu(null) + }, []) + + // Handle pane click to close context menu + const handlePaneClick = useCallback( + (event: React.MouseEvent) => { + closeNodeContextMenu() + onPaneClick(event) + }, + [onPaneClick, closeNodeContextMenu], ) return (
- - - - - + + + + - - {settings.showMiniMap && } - - - - - - - New node + connectionLineComponent={FloatingConnectionLine} + fitView + proOptions={{ hideAttribution: true }} + nodeTypes={nodeTypes} + // @ts-ignore + edgeTypes={edgeTypes} + className="!bg-background" + > + + + {settings.showMiniMap && } + + + + + + + New node + + + + + New group - - - - New group - - - - - + + + + + + {/* Node-specific context menu */} + {nodeContextMenu && ( + + )} +
) -}; +} // Memoize LayoutFlow to prevent unnecessary re-renders -const MemoizedLayoutFlow = memo(LayoutFlow); +const MemoizedLayoutFlow = memo(LayoutFlow) function Graph({ graphQuery }: { graphQuery: any }) { const [mounted, setMounted] = useState(false) @@ -327,4 +461,5 @@ function Graph({ graphQuery }: { graphQuery: any }) { } // Export the memoized Graph component -export default memo(Graph) \ No newline at end of file +export default memo(Graph) + diff --git a/flowsint-web/src/components/investigations/nodes/email.tsx b/flowsint-web/src/components/investigations/nodes/email.tsx index 7aa47da..5d81a04 100644 --- a/flowsint-web/src/components/investigations/nodes/email.tsx +++ b/flowsint-web/src/components/investigations/nodes/email.tsx @@ -1,169 +1,41 @@ -"use client" - -import { memo, useCallback, useMemo } from "react" -import { Handle, Position, useStore } from "@xyflow/react" -import { NodeProvider, useNodeContext } from "@/components/contexts/node-context" -import { AtSignIcon } from "lucide-react" -import { cn, zoomSelector } from "@/lib/utils" -import { useInvestigationStore } from "@/store/investigation-store" -import { CopyButton } from "@/components/copy" -import { useSearchContext } from "@/components/contexts/search-context" -import { Card } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import { useFlowStore } from "@/store/flow-store" -import { toast } from "sonner" -import { checkEmail } from "@/lib/actions/search" - -function EmailNode({ data }: any) { - const { handleDeleteNode, loading } = useNodeContext() - const { settings } = useInvestigationStore() - const { currentNode } = useFlowStore() - const { handleOpenSearchModal } = useSearchContext() - // Optimisation avec fonction d'égalité pour éviter les re-rendus inutiles - const showContent = useStore(zoomSelector, (a, b) => a === b) - - // Callbacks mémorisés - const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode]) - const handleSearch = useCallback(() => handleOpenSearchModal(data.email), [data.email, handleOpenSearchModal]) - - const handleCheckEmail = useCallback(() => { - toast.promise(checkEmail(data.email, data.investigation_id), { - loading: "Loading...", - success: () => { - return `Scan on ${data.email} has been launched.` - }, - error: (error: any) => { - return ( -
-

An error occured.

-
-              {JSON.stringify(error, null, 2)}
-            
-
- ) - }, - }) - }, [data.email, data.investigation_id]) - - // Mémorisation du contenu du nœud - const nodeContent = useMemo(() => { - if (settings.showNodeLabel && showContent) { - return ( - -
- - - -
- {data.label} - {settings.showCopyIcon && } -
-
-
- ) - } - - return ( - - - - - - {data.label} - - - ) - }, [settings.showNodeLabel, showContent, currentNode, data.id, data.label, settings.showCopyIcon]) - - // Mémorisation de la poignée - const handle = useMemo( - () => , - [], - ) - - // Mémorisation du menu contextuel - const contextMenu = useMemo( - () => ( - - - Search - - Associated socials - HIBPwd - - - - Copy content - ⌘ C - - - Duplicate - ⌘ D - - - - Delete - ⌘ ⌫ - - - ), - [handleCheckEmail, handleSearch, handleDelete], - ) +import React, { memo } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import { CopyButton } from '@/components/copy'; +import { Card } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { AtSignIcon } from 'lucide-react'; +export default memo(({ data }: any) => { return ( - - -
- {nodeContent} - {handle} + <> + + + +
+ + + +
+ {data.label} + +
- - {contextMenu} - - ) -} - -// Composant enveloppé avec une fonction de comparaison personnalisée -const MemoizedNode = memo( - (props: any) => ( - - - - ), - (prevProps, nextProps) => { - // Ne re-rendre que si les données importantes ont changé - return ( - prevProps.id === nextProps.id && - prevProps.data.label === nextProps.data.label && - prevProps.data.email === nextProps.data.email && - prevProps.selected === nextProps.selected - ) - }, -) - -export default MemoizedNode - +
+ + + ); +}); \ No newline at end of file diff --git a/flowsint-web/src/components/investigations/nodes/ip_address.tsx b/flowsint-web/src/components/investigations/nodes/ip_address.tsx index 535865a..bb5763c 100644 --- a/flowsint-web/src/components/investigations/nodes/ip_address.tsx +++ b/flowsint-web/src/components/investigations/nodes/ip_address.tsx @@ -1,141 +1,41 @@ -"use client" - -import { memo, useCallback, useMemo } from "react" -import { Handle, Position, useStore } from "@xyflow/react" -import { NodeProvider, useNodeContext } from "@/components/contexts/node-context" -import { LocateIcon, Zap } from "lucide-react" -import { cn, zoomSelector } from "@/lib/utils" -import { useInvestigationStore } from "@/store/investigation-store" -import { CopyButton } from "@/components/copy" -import { useSearchContext } from "@/components/contexts/search-context" -import { Card } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import { useFlowStore } from "@/store/flow-store" - -function IpNode({ data }: any) { - const { handleDeleteNode, loading } = useNodeContext() - const { settings } = useInvestigationStore() - const { currentNode } = useFlowStore() - const { handleOpenSearchModal } = useSearchContext() - // Optimisation avec fonction d'égalité pour éviter les re-rendus inutiles - const showContent = useStore(zoomSelector, (a, b) => a === b) - - // Callbacks mémorisés - const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode]) - const handleSearch = useCallback(() => handleOpenSearchModal(data.label), [data.label, handleOpenSearchModal]) - - // Mémorisation du contenu du nœud - const nodeContent = useMemo(() => { - if (settings.showNodeLabel && showContent) { - return ( - -
- - - -
- {data.label} - {settings.showCopyIcon && } -
-
-
- ) - } - - return ( - - - - - - {data.label} - - - ) - }, [settings.showNodeLabel, showContent, currentNode, data.id, data.label, settings.showCopyIcon]) - - // Mémorisation de la poignée - const handle = useMemo( - () => , - [], - ) - - // Mémorisation du menu contextuel - const contextMenu = useMemo( - () => ( - - - Launch search - - - - Copy content - ⌘ C - - - Duplicate - ⌘ D - - - - Delete - ⌘ ⌫ - - - ), - [handleSearch, handleDelete], - ) +import React, { memo } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import { CopyButton } from '@/components/copy'; +import { Card } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { AtSignIcon, LocateIcon } from 'lucide-react'; +export default memo(({ data }: any) => { return ( - - -
- {nodeContent} - {handle} + <> + + + +
+ + + +
+ {data.label} + +
- - {contextMenu} - - ) -} - -// Composant enveloppé avec une fonction de comparaison personnalisée -const MemoizedNode = memo( - (props: any) => ( - - - - ), - (prevProps, nextProps) => { - // Ne re-rendre que si les données importantes ont changé - return ( - prevProps.id === nextProps.id && - prevProps.data.label === nextProps.data.label && - prevProps.selected === nextProps.selected - ) - }, -) - -export default MemoizedNode - +
+ + + ); +}); \ No newline at end of file diff --git a/flowsint-web/src/components/investigations/nodes/minimal_individual.tsx b/flowsint-web/src/components/investigations/nodes/minimal_individual.tsx deleted file mode 100644 index 097988e..0000000 --- a/flowsint-web/src/components/investigations/nodes/minimal_individual.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"use client" - -import { memo, useMemo, useCallback } from "react" -// @ts-ignore -import { Handle, NodeToolbar, Position, useStore } from "@xyflow/react" -import { useInvestigationStore } from "@/store/investigation-store" -import { NodeProvider, useNodeContext } from "@/components/contexts/node-context" -import { useSearchContext } from "@/components/contexts/search-context" -import { cn, zoomSelector } from "@/lib/utils" -import { CopyButton } from "@/components/copy" -import { useChatContext } from "@/components/contexts/chatbot-context" -import { AtSign, Camera, Facebook, GithubIcon, Instagram, Locate, MapPin, MessageCircleDashed, Send, SquarePenIcon } from "lucide-react" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" -import { Card } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import { Edit, Phone, User, Zap } from "lucide-react" -import { useFlowStore } from "@/store/flow-store" -import { useQueryState } from "nuqs" - -// Composant optimisé pour les performances -function Custom(props: any) { - const { settings } = useInvestigationStore() - const { currentNode } = useFlowStore() - const { setOpenAddNodeModal, handleDuplicateNode, setOpenNote, handleDeleteNode, loading } = useNodeContext() - const { handleOpenSearchModal } = useSearchContext() - const { handleOpenChat } = useChatContext() - // Utilisation d'un sélecteur d'égalité pour éviter les re-rendus inutiles - const showContent = useStore(zoomSelector, (a, b) => a === b) - const [_, setIndividualId] = useQueryState("individual_id") - const { data } = props - - // Callbacks mémorisés pour éviter les recréations de fonctions - const handleEditClick = useCallback(() => setIndividualId(data.id), [data.id, setIndividualId]) - const handleChatClick = useCallback(() => handleOpenChat(data), [data, handleOpenChat]) - const handleSearchClick = useCallback( - () => handleOpenSearchModal(data.full_name), - [data.full_name, handleOpenSearchModal], - ) - const handleDuplicateClick = useCallback(() => handleDuplicateNode(), [handleDuplicateNode]) - const handleDeleteClick = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode]) - const handleNoteClick = useCallback(() => setOpenNote(true), [setOpenNote]) - const handleDoubleClick = useCallback(() => setIndividualId(data.id), [data.id, setIndividualId]) - - // Mémorisation du contenu du nœud pour éviter les recalculs - const nodeContent = useMemo(() => { - if (settings.showNodeLabel && showContent) { - return ( - -
- - - {loading ? "..." : data.full_name[0]} - - {settings.showNodeLabel && showContent && ( -
- {data.full_name} - {settings.showCopyIcon && } -
- )} -
-
- ) - } - - return ( - - - - - - {data.full_name} - - - ) - }, [settings.showNodeLabel, showContent, data, loading, currentNode, handleDoubleClick, settings.showCopyIcon]) - - // Mémorisation des handles pour éviter les recalculs - const handles = useMemo( - () => ( - <> - - - - ), - [showContent], - ) - - return ( -
-
- {nodeContent} - {handles} -
-
- ) -} - -const IndividualNode = memo( - (props: any) => ( - - - - ), - (prevProps, nextProps) => { - // Comparaison personnalisée pour éviter les re-rendus inutiles - // Ne re-rendre que si les données importantes ont changé - return ( - prevProps.id === nextProps.id && - prevProps.data.full_name === nextProps.data.full_name && - prevProps.data.image_url === nextProps.data.image_url && - prevProps.selected === nextProps.selected - ) - }, -) - -export default IndividualNode - diff --git a/flowsint-web/src/components/investigations/nodes/person.tsx b/flowsint-web/src/components/investigations/nodes/person.tsx new file mode 100644 index 0000000..686e522 --- /dev/null +++ b/flowsint-web/src/components/investigations/nodes/person.tsx @@ -0,0 +1,40 @@ +import React, { memo } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import { CopyButton } from '@/components/copy'; +import { Card } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; + +export default memo(({ data }: any) => { + return ( + <> + + + +
+ + + {data.full_name[0]} + +
+ {data.full_name} + +
+
+
+ + + ); +}); \ No newline at end of file diff --git a/flowsint-web/src/components/investigations/nodes/phone.tsx b/flowsint-web/src/components/investigations/nodes/phone.tsx index 491d5ce..1a3c912 100644 --- a/flowsint-web/src/components/investigations/nodes/phone.tsx +++ b/flowsint-web/src/components/investigations/nodes/phone.tsx @@ -1,130 +1,41 @@ -"use client" - -import { memo, useCallback, useMemo } from "react" -import { Handle, Position, useStore } from "@xyflow/react" -import { NodeProvider, useNodeContext } from "@/components/contexts/node-context" -import { LocateIcon, Zap } from "lucide-react" -import { cn, zoomSelector } from "@/lib/utils" -import { useInvestigationStore } from "@/store/investigation-store" -import { CopyButton } from "@/components/copy" -import { useSearchContext } from "@/components/contexts/search-context" -import { Card } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import { useFlowStore } from "@/store/flow-store" - -function PhoneNode({ data }: any) { - const { handleDeleteNode, loading } = useNodeContext() - const { settings } = useInvestigationStore() - const { currentNode } = useFlowStore() - const { handleOpenSearchModal } = useSearchContext() - const showContent = useStore(zoomSelector, (a, b) => a === b) - const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode]) - const handleSearch = useCallback(() => handleOpenSearchModal(data.label), [data.label, handleOpenSearchModal]) - - const nodeContent = useMemo(() => { - if (settings.showNodeLabel && showContent) { - return ( - -
- - - -
- {data.label} - {settings.showCopyIcon && } -
-
-
- ) - } - return ( - - - - - - {data.label} - - - ) - }, [settings.showNodeLabel, showContent, currentNode, data.id, data.label, settings.showCopyIcon]) - - const handle = useMemo( - () => , - [], - ) - const contextMenu = useMemo( - () => ( - - - Launch search - - - - Copy content - ⌘ C - - - Duplicate - ⌘ D - - - - Delete - ⌘ ⌫ - - - ), - [handleSearch, handleDelete], - ) +import React, { memo } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import { CopyButton } from '@/components/copy'; +import { Card } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { AtSignIcon, PhoneIcon } from 'lucide-react'; +export default memo(({ data }: any) => { return ( - - -
- {nodeContent} - {handle} + <> + + + +
+ + + +
+ {data.label} + +
- - {contextMenu} - - ) -} -const MemoizedNode = memo( - (props: any) => ( - - - - ), - (prevProps, nextProps) => { - return ( - prevProps.id === nextProps.id && - prevProps.data.label === nextProps.data.label && - prevProps.selected === nextProps.selected - ) - }, -) - -export default MemoizedNode - +
+ + + ); +}); \ No newline at end of file diff --git a/flowsint-web/src/components/investigations/nodes/physical_address.tsx b/flowsint-web/src/components/investigations/nodes/physical_address.tsx index 311dbb9..34864dc 100644 --- a/flowsint-web/src/components/investigations/nodes/physical_address.tsx +++ b/flowsint-web/src/components/investigations/nodes/physical_address.tsx @@ -1,42 +1,24 @@ -"use client" +import React, { memo } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import { CopyButton } from '@/components/copy'; +import { Card } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { AtSignIcon, LocateIcon } from 'lucide-react'; -import { memo, useCallback, useMemo } from "react" -import { Handle, Position, useStore } from "@xyflow/react" -import { NodeProvider, useNodeContext } from "@/components/contexts/node-context" -import { LocateIcon, Zap } from "lucide-react" -import { cn, zoomSelector } from "@/lib/utils" -import { useInvestigationStore } from "@/store/investigation-store" -import { CopyButton } from "@/components/copy" -import { useSearchContext } from "@/components/contexts/search-context" -import { Card } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import { useFlowStore } from "@/store/flow-store" +export default memo(({ data }: any) => { + return ( + <> + -function AddressNode({ data }: any) { - const { handleDeleteNode, loading } = useNodeContext() - const { settings } = useInvestigationStore() - const { currentNode } = useFlowStore() - const { handleOpenSearchModal } = useSearchContext() - const showContent = useStore(zoomSelector, (a, b) => a === b) - const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode]) - const handleSearch = useCallback(() => handleOpenSearchModal(data.label), [data.label, handleOpenSearchModal]) - - const nodeContent = useMemo(() => { - return (
@@ -45,68 +27,15 @@ function AddressNode({ data }: any) {
{data.label} - {settings.showCopyIcon && } +
- ) - }, [currentNode, data.id, data.label, settings.showCopyIcon]) - - const handle = useMemo( - () => , - [], - ) - const contextMenu = useMemo( - () => ( - - - Launch search - - - - Copy content - ⌘ C - - - Duplicate - ⌘ D - - - - Delete - ⌘ ⌫ - - - ), - [handleSearch, handleDelete], - ) - - return ( - - -
- {nodeContent} - {handle} -
-
- {contextMenu} -
- ) -} -const MemoizedNode = memo( - (props: any) => ( - - - - ), - (prevProps, nextProps) => { - return ( - prevProps.id === nextProps.id && - prevProps.data.label === nextProps.data.label && - prevProps.selected === nextProps.selected - ) - }, -) - -export default MemoizedNode - + + + ); +}); \ No newline at end of file diff --git a/flowsint-web/src/components/investigations/nodes/social.tsx b/flowsint-web/src/components/investigations/nodes/social.tsx index 461c9d9..0f173c3 100644 --- a/flowsint-web/src/components/investigations/nodes/social.tsx +++ b/flowsint-web/src/components/investigations/nodes/social.tsx @@ -1,163 +1,45 @@ -"use client" +import React, { memo, useMemo } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import { CopyButton } from '@/components/copy'; +import { Card } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; +import { AtSignIcon } from 'lucide-react'; +import { usePlatformIcons } from '@/lib/hooks/use-platform-icons'; -import { memo, useCallback, useMemo } from "react" -import { Handle, Position, useStore } from "@xyflow/react" -import { NodeProvider, useNodeContext } from "@/components/contexts/node-context" -import { MapPinIcon, Zap } from "lucide-react" -import { cn, zoomSelector } from "@/lib/utils" -import { useInvestigationStore } from "@/store/investigation-store" -import { CopyButton } from "@/components/copy" -import { useSearchContext } from "@/components/contexts/search-context" -import { Card } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import { usePlatformIcons } from "@/lib/hooks/use-platform-icons" -import { useFlowStore } from "@/store/flow-store" - -function SocialNode({ data }: any) { - const { handleDeleteNode, loading } = useNodeContext() - const { settings } = useInvestigationStore() - const { currentNode } = useFlowStore() +export default memo(({ data }: any) => { const platformsIcons = usePlatformIcons() - const { handleOpenSearchModal } = useSearchContext() - // Optimisation avec fonction d'égalité pour éviter les re-rendus inutiles - const showContent = useStore(zoomSelector, (a, b) => a === b) - - // Callbacks mémorisés - const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode]) - const handleSearch = useCallback(() => handleOpenSearchModal(data.label), [data.label, handleOpenSearchModal]) - - // Mémorisation de l'icône de la plateforme const platformIcon = useMemo(() => { // @ts-ignore return platformsIcons?.[data?.platform]?.icon }, [platformsIcons, data?.platform]) - - // Mémorisation du contenu du nœud - const nodeContent = useMemo(() => { - if (settings.showNodeLabel && showContent) { - return ( - -
- - {platformIcon} - -
- {data.username || data.profile_url} - {settings.showCopyIcon && ( - - )} -
-
-
- ) - } - - return ( - - - - - - {data.label} - - - ) - }, [ - settings.showNodeLabel, - showContent, - currentNode, - data.id, - data.username, - data.profile_url, - data.label, - settings.showCopyIcon, - platformIcon, - ]) - - // Mémorisation de la poignée - const handle = useMemo( - () => , - [], - ) - - // Mémorisation du menu contextuel - const contextMenu = useMemo( - () => ( - - - Launch search - - - - Copy content - ⌘ C - - - Duplicate - ⌘ D - - - - Delete - ⌘ ⌫ - - - ), - [handleSearch, handleDelete], - ) - return ( - - -
- {nodeContent} - {handle} + <> + + +
+ + {platformIcon} + +
+ {data.username || data.profile_url} + +
- - {contextMenu} - - ) -} - -// Composant enveloppé avec une fonction de comparaison personnalisée -const MemoizedNode = memo( - (props: any) => ( - - - - ), - (prevProps, nextProps) => { - // Ne re-rendre que si les données importantes ont changé - return ( - prevProps.id === nextProps.id && - prevProps.data.username === nextProps.data.username && - prevProps.data.profile_url === nextProps.data.profile_url && - prevProps.data.platform === nextProps.data.platform && - prevProps.selected === nextProps.selected - ) - }, -) - -export default MemoizedNode - +
+ + + ); +}); \ No newline at end of file diff --git a/flowsint-web/src/components/investigations/simple-floating-edge.tsx b/flowsint-web/src/components/investigations/simple-floating-edge.tsx new file mode 100644 index 0000000..1efe4b5 --- /dev/null +++ b/flowsint-web/src/components/investigations/simple-floating-edge.tsx @@ -0,0 +1,44 @@ +import { getBezierPath, getStraightPath, useInternalNode } from '@xyflow/react'; + +import { getEdgeParams } from '@/lib/utils'; + +interface FloatingEdgeProps { + id: string; + source: string; + target: string; + markerEnd?: string; + style?: React.CSSProperties; +} + +function FloatingEdge({ id, source, target, markerEnd, style }: FloatingEdgeProps) { + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + + if (!sourceNode || !targetNode) { + return null; + } + + const { sx, sy, tx, ty } = getEdgeParams( + sourceNode, + targetNode, + ); + + const [edgePath] = getStraightPath({ + sourceX: sx, + sourceY: sy, + targetX: tx, + targetY: ty, + }); + + return ( + + ); +} + +export default FloatingEdge; diff --git a/flowsint-web/src/lib/utils.ts b/flowsint-web/src/lib/utils.ts index 3a295fc..e91b21f 100644 --- a/flowsint-web/src/lib/utils.ts +++ b/flowsint-web/src/lib/utils.ts @@ -1,5 +1,7 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" +//@ts-ignore +import * as d3 from "d3-force" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -8,6 +10,7 @@ export function cn(...inputs: ClassValue[]) { export const zoomSelector = (s: { transform: number[]; }) => s.transform[2] >= 0.6; import { Position, MarkerType } from '@xyflow/react'; +import { AppNode } from "@/store/flow-store"; // this helper function returns the intersection point // of the line between the center of the intersectionNode and the target node @@ -106,4 +109,100 @@ export function initialElements() { } return { nodes, edges }; -} \ No newline at end of file +} + +interface Node { + id: string + position?: { x: number; y: number } + measured?: { width: number; height: number } + [key: string]: any +} + +interface Edge { + source: string + target: string + [key: string]: any +} + +interface LayoutOptions { + direction?: string + strength?: number + distance?: number + iterations?: number +} + +export const getLayoutedElements = ( + nodes: AppNode[], + edges: Edge[], + options: LayoutOptions = { + direction: "LR", + strength: -300, + distance: 100, + iterations: 300, + }, +) => { + // Create a map of node IDs to indices for the simulation + const nodeMap = new Map(nodes.map((node, i) => [node.id, i])) + + // Create a copy of nodes with positions for the simulation + const nodesCopy = nodes.map((node) => ({ + ...node, + x: node.position?.x || Math.random() * 500, + y: node.position?.y || Math.random() * 500, + width: node.measured?.width || 0, + height: node.measured?.height || 0, + })) + + // Create links for the simulation using indices + const links = edges.map((edge) => ({ + source: nodeMap.get(edge.source), + target: nodeMap.get(edge.target), + original: edge, + })) + + // Create the simulation + const simulation = d3 + .forceSimulation(nodesCopy) + .force( + "link", + d3.forceLink(links).id((d: any) => nodeMap.get(d.id)), + ) + .force("charge", d3.forceManyBody().strength(options.strength || -300)) + .force("center", d3.forceCenter(250, 250)) + .force( + "collision", + d3.forceCollide().radius((d: any) => Math.max(d.width, d.height) / 2 + 10), + ) + + // If direction is horizontal, adjust forces + if (options.direction === "LR") { + simulation.force("x", d3.forceX(250).strength(0.1)) + simulation.force("y", d3.forceY(250).strength(0.05)) + } else { + simulation.force("x", d3.forceX(250).strength(0.05)) + simulation.force("y", d3.forceY(250).strength(0.1)) + } + + // Run the simulation synchronously + simulation.stop() + for (let i = 0; i < (options.iterations || 300); i++) { + simulation.tick() + } + + // Update node positions based on simulation results + const updatedNodes = nodesCopy.map((node) => ({ + ...node, + position: { + x: node.x - node.width / 2, + y: node.y - node.height / 2, + }, + })) + + return { + nodes: updatedNodes, + edges, + } +} + +export default getLayoutedElements + diff --git a/flowsint-web/src/store/flow-store.ts b/flowsint-web/src/store/flow-store.ts index 1f55dd9..78930c6 100644 --- a/flowsint-web/src/store/flow-store.ts +++ b/flowsint-web/src/store/flow-store.ts @@ -8,6 +8,7 @@ import { type OnNodesChange, type OnEdgesChange, } from '@xyflow/react'; +import getLayoutedElements from '@/lib/utils'; export type AppNode = Node; @@ -29,29 +30,6 @@ export type AppState = { updateNode: (nodeId: string, nodeData: Partial) => void; resetNodeStyles: () => void; }; -const getLayoutedElements = (nodes: any[], edges: any[], options: { direction: any; }) => { - const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); - g.setGraph({ rankdir: options.direction }); - - edges.forEach((edge) => g.setEdge(edge.source, edge.target)); - nodes.forEach((node) => - g.setNode(node.id, { - ...node, - width: node.measured?.width ?? 0, - height: node.measured?.height ?? 0, - }), - ); - Dagre.layout(g); - return { - nodes: nodes.map((node) => { - const position = g.node(node.id); - const x = position.x - (node.measured?.width ?? 0) / 2; - const y = position.y - (node.measured?.height ?? 0) / 2; - return { ...node, position: { x, y } }; - }), - edges, - }; -}; const createStore = (initialNodes: AppNode[] = [], initialEdges: Edge[] = []) => { return create((set, get) => ({ nodes: initialNodes, @@ -149,6 +127,7 @@ const createStore = (initialNodes: AppNode[] = [], initialEdges: Edge[] = []) => onLayout: (direction = 'TB', fitView: () => void) => { const { nodes, edges } = getLayoutedElements(get().nodes, get().edges, { direction }); set({ nodes }); + //@ts-ignore set({ edges }); fitView(); }, diff --git a/flowsint-web/styles/globals.css b/flowsint-web/styles/globals.css index bda8e95..c0ddefb 100644 --- a/flowsint-web/styles/globals.css +++ b/flowsint-web/styles/globals.css @@ -15,7 +15,7 @@ --card-foreground: hsl(0 0% 3.9%); --popover: hsl(0 0% 100%); --popover-foreground: hsl(0 0% 3.9%); - --primary: hsl(24.6 95% 53.1%); + --primary: hsl(221.2 83.2% 53.3%); --primary-foreground: hsl(0 0% 98%); --secondary: hsl(0 0% 96.1%); --secondary-foreground: hsl(0 0% 9%); @@ -28,7 +28,7 @@ --border: hsla(0, 0%, 90%, 0.807); --input: hsl(0 0% 89.8%); --ring: hsl(0 0% 3.9%); - --chart-1: hsl(24.6 95% 53.1%); + --chart-1: hsl(221.2 83.2% 53.3%); --chart-2: hsl(173 58% 39%); --chart-3: hsl(197 37% 24%); --chart-4: hsl(43 74% 66%); @@ -43,7 +43,7 @@ --sidebar-accent: hsl(240 4.8% 95.9%); --sidebar-accent-foreground: hsl(240 5.9% 10%); --sidebar-border: hsl(220 13% 91%); - --sidebar-ring: hsl(24.6 95% 53.1%); + --sidebar-ring: hsl(221.2 83.2% 53.3%); } .dark { @@ -53,7 +53,7 @@ --card-foreground: hsl(0 0% 98%); --popover: hsl(240 0% 9.9%); --popover-foreground: hsl(0 0% 98%); - --primary: hsl(24.6 95% 53.1%); + --primary: hsl(221.2 83.2% 53.3%); --primary-foreground: hsl(0, 0%, 100%); --secondary: hsl(0 0% 14.9%); --secondary-foreground: hsl(0 0% 98%); @@ -66,14 +66,14 @@ --border: hsla(0, 0%, 23%, 0.67); --input: hsl(0 0% 14.9%); --ring: hsl(0 0% 83.1%); - --chart-1: hsl(24.6 95% 53.1%); + --chart-1: hsl(221.2 83.2% 53.3%); --chart-2: hsl(160 60% 45%); --chart-3: hsl(30 80% 55%); - --chart-4: hsl(24.6 95% 53.1%); + --chart-4: hsl(221.2 83.2% 53.3%); --chart-5: hsl(340 75% 55%); --sidebar: hsl(240 0% 9.9%); --sidebar-foreground: hsl(240 4.8% 95.9%); - --sidebar-primary: hsl(24.6 95% 53.1%); + --sidebar-primary: hsl(221.2 83.2% 53.3%); --sidebar-primary-foreground: hsl(0 0% 100%); --sidebar-accent: hsl(240 3.7% 15.9%); --sidebar-accent-foreground: hsl(240 4.8% 95.9%);