diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1d7643f..b8e1c75 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,13 +28,14 @@ const App: React.FC = () => { -
-
-
+
+
+
+
+
} /> } /> - { } /> - - +
+ +
} /> - } /> } />
diff --git a/frontend/src/api/network.ts b/frontend/src/api/network.ts index 2e1276c..60f107e 100644 --- a/frontend/src/api/network.ts +++ b/frontend/src/api/network.ts @@ -44,7 +44,7 @@ export const getUserNetworks = async (): Promise => { export const createNetwork = async (data: CreateNetworkData): Promise => { const response = await axios.post<{ success: boolean; data: Network }>( `${API_URL}/networks`, - data, + data ); return response.data.data; }; @@ -52,7 +52,7 @@ export const createNetwork = async (data: CreateNetworkData): Promise = // Get a specific network export const getNetwork = async (id: string): Promise => { const response = await axios.get<{ success: boolean; data: Network }>( - `${API_URL}/networks/${id}`, + `${API_URL}/networks/${id}` ); return response.data.data; }; @@ -61,7 +61,7 @@ export const getNetwork = async (id: string): Promise => { export const updateNetwork = async (id: string, data: UpdateNetworkData): Promise => { const response = await axios.put<{ success: boolean; data: Network }>( `${API_URL}/networks/${id}`, - data, + data ); return response.data.data; }; diff --git a/frontend/src/api/people.ts b/frontend/src/api/people.ts index 2897a7c..c7feb7f 100644 --- a/frontend/src/api/people.ts +++ b/frontend/src/api/people.ts @@ -44,7 +44,7 @@ export interface UpdatePersonData { // Get all people in a network export const getPeople = async (networkId: string): Promise => { const response = await axios.get<{ success: boolean; data: Person[] }>( - `${API_URL}/networks/${networkId}/people`, + `${API_URL}/networks/${networkId}/people` ); return response.data.data; }; @@ -53,7 +53,7 @@ export const getPeople = async (networkId: string): Promise => { export const addPerson = async (networkId: string, data: CreatePersonData): Promise => { const response = await axios.post<{ success: boolean; data: Person }>( `${API_URL}/networks/${networkId}/people`, - data, + data ); return response.data.data; }; @@ -62,11 +62,11 @@ export const addPerson = async (networkId: string, data: CreatePersonData): Prom export const updatePerson = async ( networkId: string, personId: string, - data: UpdatePersonData, + data: UpdatePersonData ): Promise => { const response = await axios.put<{ success: boolean; data: Person }>( `${API_URL}/networks/${networkId}/people/${personId}`, - data, + data ); return response.data.data; }; diff --git a/frontend/src/api/relationships.ts b/frontend/src/api/relationships.ts index 6ec142a..979a6f6 100644 --- a/frontend/src/api/relationships.ts +++ b/frontend/src/api/relationships.ts @@ -23,7 +23,7 @@ export interface UpdateRelationshipData { // Get all relationships in a network export const getRelationships = async (networkId: string): Promise => { const response = await axios.get<{ success: boolean; data: Relationship[] }>( - `${API_URL}/networks/${networkId}/relationships`, + `${API_URL}/networks/${networkId}/relationships` ); return response.data.data; }; @@ -31,11 +31,11 @@ export const getRelationships = async (networkId: string): Promise => { const response = await axios.post<{ success: boolean; data: Relationship }>( `${API_URL}/networks/${networkId}/relationships`, - data, + data ); return response.data.data; }; @@ -44,11 +44,11 @@ export const addRelationship = async ( export const updateRelationship = async ( networkId: string, relationshipId: string, - data: UpdateRelationshipData, + data: UpdateRelationshipData ): Promise => { const response = await axios.put<{ success: boolean; data: Relationship }>( `${API_URL}/networks/${networkId}/relationships/${relationshipId}`, - data, + data ); return response.data.data; }; @@ -56,7 +56,7 @@ export const updateRelationship = async ( // Remove a relationship export const removeRelationship = async ( networkId: string, - relationshipId: string, + relationshipId: string ): Promise => { await axios.delete(`${API_URL}/networks/${networkId}/relationships/${relationshipId}`); }; diff --git a/frontend/src/app.css b/frontend/src/app.css index d4b5078..1f5961d 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1 +1,9 @@ @import 'tailwindcss'; + +.header-height { + height: 64px; +} + +.h-full-important { + height: 100% !important; +} diff --git a/frontend/src/components/CanvasGraph.tsx b/frontend/src/components/CanvasGraph.tsx index 2581c87..3435dd5 100644 --- a/frontend/src/components/CanvasGraph.tsx +++ b/frontend/src/components/CanvasGraph.tsx @@ -1,7 +1,10 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { GraphData } from 'react-force-graph-2d'; -// Define types for graph elements +interface GraphData { + nodes: any[]; + links?: any[]; +} + interface NodeData { id: string; firstName: string; @@ -26,18 +29,15 @@ interface CustomGraphData extends GraphData { } interface CanvasGraphProps { - data: CustomGraphData, - width: number, - height: number, - zoomLevel: number, - onNodeClick: (nodeId: string) => void, - onNodeDrag: (nodeId, x, y) => void + data: CustomGraphData; + width: number; + height: number; } // Physics constants -const NODE_RADIUS = 45; // Node radius in pixels -const MIN_DISTANCE = 110; // Minimum distance between any two nodes -const MAX_DISTANCE = 500; // Maximum distance between connected nodes +const NODE_RADIUS = 30; // Node radius in pixels +const MIN_DISTANCE = 100; // Minimum distance between any two nodes +const MAX_DISTANCE = 300; // Maximum distance between connected nodes const REPULSION_STRENGTH = 500; // How strongly nodes repel each other when too close const ATTRACTION_STRENGTH = 0.1; // Default attraction between connected nodes const CONSTRAINT_STRENGTH = 0.2; // Strength of distance constraints @@ -46,10 +46,9 @@ const CENTER_GRAVITY = 0.01; // Force pulling nodes to the center const MAX_VELOCITY = 5; // Maximum velocity to prevent wild movement const COOLING_FACTOR = 0.99; // System gradually cools down -const CanvasGraph: React.FC = ({ data, width, height, zoomLevel, onNodeClick, onNodeDrag }) => { +const CanvasGraph: React.FC = ({ data, width, height }) => { const canvasRef = useRef(null); - // State for interactions const [draggedNode, setDraggedNode] = useState(null); const [hoveredNode, setHoveredNode] = useState(null); const [offsetX, setOffsetX] = useState(0); @@ -60,7 +59,6 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve const [scale, setScale] = useState(1); const [autoLayout, setAutoLayout] = useState(true); - // Node physics state const [nodePositions, setNodePositions] = useState< Record< string, @@ -73,19 +71,16 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve > >({}); - // Animation frame reference const animationRef = useRef(null); useEffect(() => { - // Only run once when component mounts or when data.nodes changes if (width <= 0 || height <= 0 || !data.nodes || data.nodes.length === 0) return; console.log('Initializing node positions...'); - // Skip if we already have positions for all nodes const allNodesHavePositions = data.nodes.every( node => - nodePositions[node.id] && (nodePositions[node.id].x !== 0 || nodePositions[node.id].y !== 0), + nodePositions[node.id] && (nodePositions[node.id].x !== 0 || nodePositions[node.id].y !== 0) ); if (allNodesHavePositions) { @@ -93,29 +88,21 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve return; } - // Create initial positions object const initialPositions: Record = {}; - // Determine the area to place nodes const padding = NODE_RADIUS * 2; const availableWidth = width - padding * 2; const availableHeight = height - padding * 2; - // Calculate a grid layout - find grid dimensions based on node count const nodeCount = data.nodes.length; const aspectRatio = availableWidth / availableHeight; const gridCols = Math.ceil(Math.sqrt(nodeCount * aspectRatio)); const gridRows = Math.ceil(nodeCount / gridCols); - console.log(`Creating a ${gridCols}x${gridRows} grid for ${nodeCount} nodes`); - - // Calculate cell size const cellWidth = availableWidth / gridCols; const cellHeight = availableHeight / gridRows; - // Position each node in a grid cell with random offset data.nodes.forEach((node, index) => { - // Only generate new position if node doesn't already have one if ( nodePositions[node.id] && nodePositions[node.id].x !== 0 && @@ -129,15 +116,12 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve return; } - // Calculate grid position const row = Math.floor(index / gridCols); const col = index % gridCols; - // Add randomness within cell (20% of cell size) const randomOffsetX = cellWidth * 0.4 * (Math.random() - 0.5); const randomOffsetY = cellHeight * 0.4 * (Math.random() - 0.5); - // Calculate final position const x = padding + cellWidth * (col + 0.5) + randomOffsetX; const y = padding + cellHeight * (row + 0.5) + randomOffsetY; @@ -147,22 +131,14 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve vx: 0, vy: 0, }; - - console.log(`Node ${node.id} positioned at (${x.toFixed(2)}, ${y.toFixed(2)})`); }); - // Set positions in one batch update setNodePositions(initialPositions); - - console.log('Node positioning complete'); }, [data.nodes, width, height]); - // Run physics simulation - FIX: Added proper dependencies useEffect(() => { - // Only proceed if we have valid dimensions and data if (width <= 0 || height <= 0 || !data.nodes || data.nodes.length === 0) return; - // Debug: Force at least one draw call to make sure graph is initially visible drawGraph(); if (!autoLayout || draggedNode) { @@ -177,34 +153,28 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve setNodePositions(prevPositions => { const newPositions = { ...prevPositions }; - // Apply forces to each node data.nodes.forEach(node => { if (!newPositions[node.id]) return; - // Skip if this node is being dragged if (node.id === draggedNode) return; let forceX = 0; let forceY = 0; - // Center gravity force const centerX = width / 2; const centerY = height / 2; forceX += (centerX - newPositions[node.id].x) * CENTER_GRAVITY; forceY += (centerY - newPositions[node.id].y) * CENTER_GRAVITY; - // Repulsion forces (from ALL other nodes to prevent overlapping) data.nodes.forEach(otherNode => { if (node.id === otherNode.id || !newPositions[otherNode.id]) return; const dx = newPositions[node.id].x - newPositions[otherNode.id].x; const dy = newPositions[node.id].y - newPositions[otherNode.id].y; const distanceSq = dx * dx + dy * dy; - const distance = Math.sqrt(distanceSq) || 1; // Avoid division by zero + const distance = Math.sqrt(distanceSq) || 1; - // Enforce minimum distance between any two nodes if (distance < MIN_DISTANCE) { - // Strong repulsion force that increases as nodes get closer const repulsionFactor = 1 - distance / MIN_DISTANCE; const repulsionForce = REPULSION_STRENGTH * repulsionFactor * repulsionFactor; @@ -213,7 +183,6 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve } }); - // Find connected nodes (neighbors) for the current node const connectedNodeIds = new Set(); data.edges.forEach(edge => { if (edge.source === node.id) { @@ -223,7 +192,6 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve } }); - // Attraction forces (only to connected nodes) connectedNodeIds.forEach(targetId => { if (!newPositions[targetId]) return; @@ -231,18 +199,13 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve const dy = newPositions[targetId].y - newPositions[node.id].y; const distance = Math.sqrt(dx * dx + dy * dy) || 1; - // Enforce maximum distance constraint between connected nodes if (distance > MAX_DISTANCE) { - // Strong attractive force that increases as distance exceeds max const excessDistance = distance - MAX_DISTANCE; const constraintForce = CONSTRAINT_STRENGTH * excessDistance; forceX += (dx / distance) * constraintForce; forceY += (dy / distance) * constraintForce; - } - // Regular attraction between connected nodes (weaker when close) - else { - // Linear attraction normalized by MAX_DISTANCE + } else { const normalizedDistance = distance / MAX_DISTANCE; const attractionForce = ATTRACTION_STRENGTH * normalizedDistance; @@ -251,33 +214,28 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve } }); - // Update velocity with applied forces and damping newPositions[node.id].vx = newPositions[node.id].vx * DAMPING + forceX; newPositions[node.id].vy = newPositions[node.id].vy * DAMPING + forceY; - // Limit maximum velocity to prevent wild movement const speed = Math.sqrt( newPositions[node.id].vx * newPositions[node.id].vx + - newPositions[node.id].vy * newPositions[node.id].vy, + newPositions[node.id].vy * newPositions[node.id].vy ); if (speed > MAX_VELOCITY) { newPositions[node.id].vx = (newPositions[node.id].vx / speed) * MAX_VELOCITY; newPositions[node.id].vy = (newPositions[node.id].vy / speed) * MAX_VELOCITY; } - // Apply cooling factor to gradually slow the system newPositions[node.id].vx *= COOLING_FACTOR; newPositions[node.id].vy *= COOLING_FACTOR; - // Update position newPositions[node.id].x += newPositions[node.id].vx; newPositions[node.id].y += newPositions[node.id].vy; - // Boundary constraints const padding = NODE_RADIUS; if (newPositions[node.id].x < padding) { newPositions[node.id].x = padding; - newPositions[node.id].vx *= -0.5; // Bounce back + newPositions[node.id].vx *= -0.5; } if (newPositions[node.id].x > width - padding) { newPositions[node.id].x = width - padding; @@ -307,28 +265,23 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve animationRef.current = null; } }; - }, [data.nodes, data.edges, width, height, autoLayout, draggedNode]); // FIX: Added proper dependencies + }, [data.nodes, data.edges, width, height, autoLayout, draggedNode]); - // Find node at position function const findNodeAtPosition = useCallback( (x: number, y: number): string | null => { - // Transform coordinates based on scale and pan const transformedX = (x - panOffset.x) / scale; const transformedY = (y - panOffset.y) / scale; - // Iterate through nodes in reverse order (top-most first) for (let i = data.nodes.length - 1; i >= 0; i--) { const node = data.nodes[i]; const pos = nodePositions[node.id]; if (!pos) continue; - // Calculate distance from click to node center const dx = pos.x - transformedX; const dy = pos.y - transformedY; const distance = Math.sqrt(dx * dx + dy * dy); - // If click is inside node radius, return node id if (distance <= NODE_RADIUS) { return node.id; } @@ -336,32 +289,27 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve return null; }, - [data.nodes, nodePositions, panOffset, scale], - ); // FIX: Added proper dependencies + [data.nodes, nodePositions, panOffset, scale] + ); - // Mouse event handlers const handleMouseDown = useCallback( (e: React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas) return; - // Get click position relative to canvas const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; - // Find if a node was clicked const nodeId = findNodeAtPosition(x, y); if (nodeId) { - // Set dragged node and calculate offset setDraggedNode(nodeId); const transformedX = (x - panOffset.x) / scale; const transformedY = (y - panOffset.y) / scale; setOffsetX(transformedX - nodePositions[nodeId].x); setOffsetY(transformedY - nodePositions[nodeId].y); - // Reset velocity when starting to drag setNodePositions(prev => ({ ...prev, [nodeId]: { @@ -371,35 +319,29 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve }, })); } else { - // Start panning setIsPanning(true); setPanStart({ x, y }); } }, - [findNodeAtPosition, nodePositions, panOffset, scale], - ); // FIX: Added proper dependencies + [findNodeAtPosition, nodePositions, panOffset, scale] + ); const handleMouseMove = useCallback( (e: React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas) return; - // Get mouse position relative to canvas const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; - // Update hovered node const nodeId = findNodeAtPosition(x, y); setHoveredNode(nodeId); - // Handle dragging a node if (draggedNode) { - // Transform coordinates based on scale and pan const transformedX = (x - panOffset.x) / scale; const transformedY = (y - panOffset.y) / scale; - // Update node position setNodePositions(prev => ({ ...prev, [draggedNode]: { @@ -410,9 +352,7 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve vy: 0, }, })); - } - // Handle panning - else if (isPanning) { + } else if (isPanning) { const dx = x - panStart.x; const dy = y - panStart.y; setPanOffset(prev => ({ @@ -422,11 +362,10 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve setPanStart({ x, y }); } }, - [findNodeAtPosition, draggedNode, isPanning, offsetX, offsetY, panOffset, panStart, scale], - ); // FIX: Added proper dependencies + [findNodeAtPosition, draggedNode, isPanning, offsetX, offsetY, panOffset, panStart, scale] + ); const handleMouseUp = useCallback(() => { - // End any drag or pan operation setDraggedNode(null); setIsPanning(false); }, []); @@ -435,37 +374,30 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve (e: React.WheelEvent) => { e.preventDefault(); - // Get mouse position relative to canvas const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; - // Calculate zoom factor const scaleFactor = e.deltaY < 0 ? 1.1 : 0.9; const newScale = Math.max(0.1, Math.min(5, scale * scaleFactor)); - // Calculate new pan offset so that point under mouse stays fixed - // This is the key part for zooming toward mouse position const newPanOffsetX = mouseX - (mouseX - panOffset.x) * (newScale / scale); const newPanOffsetY = mouseY - (mouseY - panOffset.y) * (newScale / scale); - // Update state setScale(newScale); setPanOffset({ x: newPanOffsetX, y: newPanOffsetY }); }, - [scale, panOffset], + [scale, panOffset] ); const toggleAutoLayout = useCallback(() => { setAutoLayout(prev => !prev); }, []); - // Draw controls function const drawControls = useCallback( (ctx: CanvasRenderingContext2D) => { - // Auto layout toggle button ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; ctx.fillRect(width - 120, 20, 100, 40); @@ -475,10 +407,9 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve ctx.textBaseline = 'middle'; ctx.fillText(autoLayout ? 'Physics: ON' : 'Physics: OFF', width - 70, 40); }, - [autoLayout, width], + [autoLayout, width] ); - // Draw function - FIX: Properly memoized with all dependencies const drawGraph = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -486,26 +417,20 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve const ctx = canvas.getContext('2d'); if (!ctx) return; - // Skip drawing if dimensions are invalid if (width <= 0 || height <= 0) return; - // Set canvas dimensions to match container - // NOTE: Setting canvas width/height clears the canvas, so only do this if needed if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } - // Clear canvas ctx.fillStyle = '#0f172a'; // Slate-900 ctx.fillRect(0, 0, width, height); - // Apply transformation (scale and pan) ctx.save(); ctx.translate(panOffset.x, panOffset.y); ctx.scale(scale, scale); - // Draw edges data.edges.forEach(edge => { const sourceNode = data.nodes.find(n => n.id === edge.source); const targetNode = data.nodes.find(n => n.id === edge.target); @@ -519,15 +444,12 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve ctx.moveTo(sourcePos.x, sourcePos.y); ctx.lineTo(targetPos.x, targetPos.y); - // Edge styling let highlighted = false; if (hoveredNode) { highlighted = edge.source === hoveredNode || edge.target === hoveredNode; } - ctx.strokeStyle = highlighted - ? '#3b82f6' // bright blue for highlighted edges - : edge.color || 'rgba(255, 255, 255, 0.5)'; + ctx.strokeStyle = highlighted ? '#3b82f6' : edge.color || 'rgba(255, 255, 255, 0.5)'; ctx.lineWidth = highlighted ? (edge.width ? edge.width + 1 : 3) : edge.width || 1; @@ -536,56 +458,47 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve } }); - // Draw nodes data.nodes.forEach(node => { const pos = nodePositions[node.id]; if (!pos) return; - // Node styling based on state const isHovered = node.id === hoveredNode; const isDragged = node.id === draggedNode; - // Glow effect for hovered or dragged nodes if (isHovered || isDragged) { ctx.shadowColor = isDragged ? '#ff9900' : '#3b82f6'; ctx.shadowBlur = 15; } - // Draw node circle ctx.beginPath(); ctx.arc(pos.x, pos.y, NODE_RADIUS, 0, 2 * Math.PI); - // Fill style based on state if (isDragged) { - ctx.fillStyle = '#ff9900'; // Orange for dragged node + ctx.fillStyle = '#ff9900'; } else if (isHovered) { - ctx.fillStyle = '#3b82f6'; // Blue for hovered node + ctx.fillStyle = '#3b82f6'; } else { - ctx.fillStyle = node.bgColor || '#475569'; // Default to slate-600 + ctx.fillStyle = node.bgColor || '#475569'; } ctx.fill(); - // Draw border ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; ctx.strokeStyle = 'white'; ctx.lineWidth = 2; ctx.stroke(); - // Draw initials - const initials = `${node.firstName} ${node.lastName.charAt(0)}.`; + const initials = `${node.firstName.charAt(0)}${node.lastName.charAt(0)}`; ctx.fillStyle = 'white'; - ctx.font = 'bold 13px sans-serif'; + ctx.font = 'bold 16px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(initials, pos.x, pos.y); - // Draw name label for hovered or dragged nodes if (isHovered || isDragged) { const fullName = `${node.firstName} ${node.lastName}`; ctx.font = '14px sans-serif'; - // Add a background for the label const textMetrics = ctx.measureText(fullName); const textWidth = textMetrics.width; const textHeight = 20; @@ -596,7 +509,7 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve pos.x - textWidth / 2 - padding, pos.y + NODE_RADIUS + 5, textWidth + padding * 2, - textHeight + padding * 2, + textHeight + padding * 2 ); ctx.fillStyle = 'white'; @@ -604,10 +517,8 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve } }); - // Restore canvas transformation ctx.restore(); - // Draw UI controls drawControls(ctx); }, [ data, @@ -619,25 +530,20 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve width, height, drawControls, - ]); // FIX: Added all dependencies - - // Handle clicks on controls + ]); const handleControlClick = useCallback( (e: React.MouseEvent) => { const x = e.nativeEvent.offsetX; const y = e.nativeEvent.offsetY; - // Check if auto layout button was clicked if (x >= width - 120 && x <= width - 20 && y >= 20 && y <= 60) { toggleAutoLayout(); } }, - [width, toggleAutoLayout], - ); // FIX: Added proper dependencies + [width, toggleAutoLayout] + ); - // FIX: Ensure continuous rendering with requestAnimationFrame useEffect(() => { - // Create a continuous rendering loop that doesn't depend on physics updates let animationFrameId: number; const renderLoop = () => { @@ -645,10 +551,8 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve animationFrameId = requestAnimationFrame(renderLoop); }; - // Start the render loop animationFrameId = requestAnimationFrame(renderLoop); - // Clean up return () => { if (animationFrameId) { cancelAnimationFrame(animationFrameId); @@ -656,7 +560,6 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve }; }, [drawGraph]); - // Get cursor style based on current state const getCursorStyle = useCallback(() => { if (draggedNode) return 'grabbing'; if (hoveredNode) return 'grab'; @@ -664,19 +567,16 @@ const CanvasGraph: React.FC = ({ data, width, height, zoomLeve return 'default'; }, [draggedNode, hoveredNode, isPanning]); - // FIX: Initial rendering - make sure canvas is properly initialized useEffect(() => { - // Force the initial draw if (width > 0 && height > 0 && canvasRef.current) { const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); if (ctx) { canvas.width = width; canvas.height = height; - ctx.fillStyle = '#0f172a'; // Slate-900 + ctx.fillStyle = '#0f172a'; ctx.fillRect(0, 0, width, height); - // Draw a loading message until nodes are positioned if (data.nodes.length > 0 && Object.keys(nodePositions).length === 0) { ctx.fillStyle = 'white'; ctx.font = '16px sans-serif'; diff --git a/frontend/src/components/FriendshipNetwork.tsx b/frontend/src/components/FriendshipNetwork.tsx index 2da3328..7952810 100644 --- a/frontend/src/components/FriendshipNetwork.tsx +++ b/frontend/src/components/FriendshipNetwork.tsx @@ -1,142 +1,86 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { RefObject, useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useFriendshipNetwork } from '../hooks/useFriendshipNetwork'; import { useNetworks } from '../context/NetworkContext'; -import DatePicker from 'react-datepicker'; -import 'react-datepicker/dist/react-datepicker.css'; -import { Transition } from '@headlessui/react'; import { FaArrowLeft, - FaChevronLeft, - FaChevronRight, - FaCog, - FaCompress, - FaEdit, FaExclamationTriangle, - FaHome, FaInfo, - FaPlus, - FaRedo, - FaRegCalendarAlt, - FaSave, - FaSearch, - FaSearchMinus, - FaSearchPlus, - FaStar, FaTimes, - FaTrash, - FaUserCircle, FaUserFriends, FaUserPlus, } from 'react-icons/fa'; -// Import custom UI components -import { - Button, - Card, - CardBody, - ConfirmDialog, - EmptyState, - FormField, - Modal, - NetworkStats, - Toast, - ToastItem, - Tooltip, -} from './FriendshipNetworkComponents'; - -// Import visible canvas graph component +import { Button, ConfirmDialog, Toast } from './FriendshipNetworkComponents'; +import NetworkSidebar from './NetworkSidebar'; import CanvasGraph from './CanvasGraph'; -import { getRelationshipColor, RELATIONSHIP_TYPES, RELATIONSHIPS } from '../types/RelationShipTypes'; -import { FormErrors, PersonNode } from '../interfaces/IPersonNode'; +import { getRelationshipColor } from '../types/RelationShipTypes'; -// Main FriendshipNetwork component -const FriendshipNetwork: React.FC = () => { - const { id } = useParams<{ id: string }>(); - const { networks } = useNetworks(); - const navigate = useNavigate(); - const graphContainerRef = useRef(null); +import { + PersonNode, + RelationshipEdge, + FormErrors, + NewPersonForm, + NewRelationshipForm, + ToastItem, +} from '../types/network'; + +import { + PersonFormModal, + RelationshipFormModal, + PersonDetailModal, + SettingsModal, + HelpModal, +} from './Modals'; + +import { LoadingSpinner } from '../components/UIComponents'; + +const DEFAULT_SETTINGS = { + darkMode: true, + autoLayout: true, + showLabels: true, + animationSpeed: 'medium', + highlightConnections: true, + nodeSize: 'medium', +}; + +export const useToastNotifications = () => { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback( + (message: string, type: 'error' | 'success' | 'warning' | 'info' = 'success') => { + const id = Date.now(); + const newToast = { + id, + message, + type, + onClose: () => removeToast(id), + }; + + setToasts(prevToasts => [...prevToasts, newToast]); + + setTimeout(() => removeToast(id), 3000); + }, + [] + ); + + const removeToast = useCallback((id: number) => { + setToasts(prevToasts => prevToasts.filter(toast => toast.id !== id)); + }, []); + + return { toasts, addToast, removeToast }; +}; + +/** + * Hook for managing graph container dimensions and handling resize events + */ +export const useGraphDimensions = ( + graphContainerRef: RefObject, + sidebarOpen: boolean +) => { const [graphDimensions, setGraphDimensions] = useState({ width: 0, height: 0 }); - // Network data state from custom hook - const { - people, - relationships, - loading, - error, - createPerson, - updatePerson, - deletePerson, - createRelationship, - deleteRelationship, - refreshNetwork, - updatePersonPosition: updatePersonPositionImpl = (id: string, position: { x: number; y: number }) => { - console.warn('updatePersonPosition not implemented'); - return Promise.resolve(); - }, - } = useFriendshipNetwork(id || null) as any; - - // Create a type-safe wrapper for updatePersonPosition - const updatePersonPosition = (id: string, position: { x: number; y: number }) => { - return updatePersonPositionImpl(id, position); - }; - - // Local UI state - const [sidebarOpen, setSidebarOpen] = useState(true); - const [sidebarTab, setSidebarTab] = useState('overview'); - const [zoomLevel, setZoomLevel] = useState(1); - const [toasts, setToasts] = useState([]); - const [interactionHint, setInteractionHint] = useState(true); - - // Modal states - const [personModalOpen, setPersonModalOpen] = useState(false); - const [relationshipModalOpen, setRelationshipModalOpen] = useState(false); - const [personDetailModalOpen, setPersonDetailModalOpen] = useState(false); - const [settingsModalOpen, setSettingsModalOpen] = useState(false); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [helpModalOpen, setHelpModalOpen] = useState(false); - const [itemToDelete, setItemToDelete] = useState<{ type: string; id: string }>({ - type: '', id: '', - }); - - // Form errors - const [personFormErrors, setPersonFormErrors] = useState({}); - const [relationshipFormErrors, setRelationshipFormErrors] = useState({}); - - // Form states - const [newPerson, setNewPerson] = useState({ - firstName: '', lastName: '', birthday: null as Date | null, notes: '', - }); - - const [editPerson, setEditPerson] = useState(null); - - const [newRelationship, setNewRelationship] = useState({ - source: '', target: '', type: 'friend' as RELATIONSHIP_TYPES, customType: '', notes: '', bidirectional: true, - }); - - // Filter states - const [peopleFilter, setPeopleFilter] = useState(''); - const [relationshipFilter, setRelationshipFilter] = useState(''); - const [relationshipTypeFilter, setRelationshipTypeFilter] = useState('all'); - - // Settings state - const [settings, setSettings] = useState({ - darkMode: true, - autoLayout: true, - showLabels: true, - animationSpeed: 'medium', - highlightConnections: true, - nodeSize: 'medium', - }); - - // Selected person state for highlighting - const [selectedPersonId, setSelectedPersonId] = useState(null); - - // Get current network info - const currentNetwork = networks.find(network => network._id === id); - - // Effect for graph container dimensions useEffect(() => { if (!graphContainerRef.current) return; @@ -186,18 +130,32 @@ const FriendshipNetwork: React.FC = () => { return () => clearTimeout(timeoutId); }, [sidebarOpen]); - // Dismiss interaction hint after 10 seconds - useEffect(() => { - if (interactionHint) { - const timer = setTimeout(() => { - setInteractionHint(false); - }, 10000); - return () => clearTimeout(timer); - } - }, [interactionHint]); + return graphDimensions; +}; - // Keyboard shortcuts +/** + * Hook for setting up keyboard shortcuts + */ +export const useKeyboardShortcuts = (handlers: { + handleZoomIn: () => void; + handleZoomOut: () => void; + handleResetZoom: () => void; + toggleSidebar: () => void; + setPersonModalOpen: (open: boolean) => void; + setRelationshipModalOpen: (open: boolean) => void; + setHelpModalOpen: (open: boolean) => void; +}) => { useEffect(() => { + const { + handleZoomIn, + handleZoomOut, + handleResetZoom, + toggleSidebar, + setPersonModalOpen, + setRelationshipModalOpen, + setHelpModalOpen, + } = handlers; + const handleKeyDown = (e: KeyboardEvent) => { // Only apply shortcuts when not in an input field const target = e.target as HTMLElement; @@ -248,48 +206,22 @@ const FriendshipNetwork: React.FC = () => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, []); + }, [handlers]); +}; - // Filtered people and relationships - const filteredPeople = people.filter(person => `${person.firstName} ${person.lastName}`.toLowerCase().includes(peopleFilter.toLowerCase())); - - const filteredRelationships = relationships.filter(rel => { - const source = people.find(p => p._id === rel.source); - const target = people.find(p => p._id === rel.target); - - if (!source || !target) return false; - - const matchesFilter = `${source.firstName} ${source.lastName} ${target.firstName} ${target.lastName}` - .toLowerCase() - .includes(relationshipFilter.toLowerCase()); - - const matchesType = relationshipTypeFilter === 'all' || rel.type === relationshipTypeFilter; - - return matchesFilter && matchesType; - }); - - // Add toast notification - const addToast = (message: string, type: 'error' | 'success' | 'warning' | 'info' = 'success') => { - const id = Date.now(); - setToasts(prevToasts => [...prevToasts, { id, message, type, onClose: () => removeToast(id) }]); - - // Auto-remove after 3 seconds - setTimeout(() => { - removeToast(id); - }, 3000); - }; - - // Remove toast notification - const removeToast = (id: number) => { - setToasts(prevToasts => prevToasts.filter(toast => toast.id !== id)); - }; - - // Smart node placement for new people - const getSmartNodePosition = useCallback(() => { - const centerX = graphDimensions.width / 2; - const centerY = graphDimensions.height / 2; - const maxRadius = Math.min(graphDimensions.width, graphDimensions.height) * 0.4; - const totalNodes = people.length; +/** + * Hook to manage node positions in the graph + */ +export const useSmartNodePositioning = ( + graphWidth: number, + graphHeight: number, + peopleCount: number +) => { + return useCallback(() => { + const centerX = graphWidth / 2; + const centerY = graphHeight / 2; + const maxRadius = Math.min(graphWidth, graphHeight) * 0.4; + const totalNodes = peopleCount; const index = totalNodes; if (totalNodes <= 0) { @@ -298,17 +230,21 @@ const FriendshipNetwork: React.FC = () => { const theta = index * 2.399; const radius = maxRadius * 0.5 * Math.sqrt(index / (totalNodes + 1)); return { - x: centerX + radius * Math.cos(theta), y: centerY + radius * Math.sin(theta), + x: centerX + radius * Math.cos(theta), + y: centerY + radius * Math.sin(theta), }; } else if (totalNodes <= 11) { const isOuterRing = index >= Math.floor(totalNodes / 2); const ringIndex = isOuterRing ? index - Math.floor(totalNodes / 2) : index; - const ringTotal = isOuterRing ? totalNodes - Math.floor(totalNodes / 2) + 1 : Math.floor(totalNodes / 2); + const ringTotal = isOuterRing + ? totalNodes - Math.floor(totalNodes / 2) + 1 + : Math.floor(totalNodes / 2); const ringRadius = isOuterRing ? maxRadius * 0.8 : maxRadius * 0.4; const angle = (ringIndex / ringTotal) * 2 * Math.PI + (isOuterRing ? 0 : Math.PI / ringTotal); return { - x: centerX + ringRadius * Math.cos(angle), y: centerY + ringRadius * Math.sin(angle), + x: centerX + ringRadius * Math.cos(angle), + y: centerY + ringRadius * Math.sin(angle), }; } else { const clusterCount = Math.max(3, Math.floor(Math.sqrt(totalNodes))); @@ -324,10 +260,133 @@ const FriendshipNetwork: React.FC = () => { const randomDistance = Math.random() * clusterRadius; return { - x: clusterX + randomDistance * Math.cos(randomAngle), y: clusterY + randomDistance * Math.sin(randomAngle), + x: clusterX + randomDistance * Math.cos(randomAngle), + y: clusterY + randomDistance * Math.sin(randomAngle), }; } - }, [graphDimensions.width, graphDimensions.height, people.length]); + }, [graphWidth, graphHeight, peopleCount]); +}; + +/** + * Main FriendshipNetwork component + */ +const FriendshipNetwork: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const { networks } = useNetworks(); + const navigate = useNavigate(); + const graphContainerRef = useRef(null); + + // Network data state from custom hook + const { + people, + relationships, + loading, + error, + createPerson, + updatePerson, + deletePerson, + createRelationship, + deleteRelationship, + refreshNetwork, + updatePersonPosition: updatePersonPositionImpl = ( + id: string, + position: { x: number; y: number } + ) => { + console.warn('updatePersonPosition not implemented'); + return Promise.resolve(); + }, + } = useFriendshipNetwork(id || null) as any; + + // UI state + const [sidebarOpen, setSidebarOpen] = useState(true); + const [sidebarTab, setSidebarTab] = useState('people'); + const [zoomLevel, setZoomLevel] = useState(1); + const [interactionHint, setInteractionHint] = useState(true); + const [selectedPersonId, setSelectedPersonId] = useState(null); + + // Custom hooks + const { toasts, addToast, removeToast } = useToastNotifications(); + const graphDimensions = useGraphDimensions( + graphContainerRef as React.RefObject, + sidebarOpen + ); + const getSmartNodePosition = useSmartNodePositioning( + graphDimensions.width, + graphDimensions.height, + people.length + ); + + // Modal states + const [personModalOpen, setPersonModalOpen] = useState(false); + const [relationshipModalOpen, setRelationshipModalOpen] = useState(false); + const [personDetailModalOpen, setPersonDetailModalOpen] = useState(false); + const [settingsModalOpen, setSettingsModalOpen] = useState(false); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [helpModalOpen, setHelpModalOpen] = useState(false); + const [itemToDelete, setItemToDelete] = useState<{ type: string; id: string }>({ + type: '', + id: '', + }); + + // Form errors + const [personFormErrors, setPersonFormErrors] = useState({}); + const [relationshipFormErrors, setRelationshipFormErrors] = useState({}); + + // Form states + const [newPerson, setNewPerson] = useState({ + firstName: '', + lastName: '', + birthday: null, + notes: '', + }); + + const [editPerson, setEditPerson] = useState(null); + + const [newRelationship, setNewRelationship] = useState({ + source: '', + target: '', + type: 'friend', + customType: '', + notes: '', + bidirectional: true, + }); + + // Filter states + const [peopleFilter, setPeopleFilter] = useState(''); + const [relationshipFilter, setRelationshipFilter] = useState(''); + const [relationshipTypeFilter, setRelationshipTypeFilter] = useState('all'); + + // Settings state + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + + // Get current network info + const currentNetwork = networks.find(network => network._id === id); + + // Dismiss interaction hint after 10 seconds + React.useEffect(() => { + if (interactionHint) { + const timer = setTimeout(() => { + setInteractionHint(false); + }, 10000); + return () => clearTimeout(timer); + } + }, [interactionHint]); + + // Register keyboard shortcuts + const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 0.2, 2.5)); + const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 0.2, 0.5)); + const handleResetZoom = () => setZoomLevel(1); + const toggleSidebar = () => setSidebarOpen(!sidebarOpen); + + useKeyboardShortcuts({ + handleZoomIn, + handleZoomOut, + handleResetZoom, + toggleSidebar, + setPersonModalOpen, + setRelationshipModalOpen, + setHelpModalOpen, + }); // Transform API data to graph format const getGraphData = useCallback(() => { @@ -336,12 +395,20 @@ const FriendshipNetwork: React.FC = () => { } // Create nodes - const graphNodes = people.map(person => { - const connectionCount = relationships.filter(r => r.source === person._id || r.target === person._id).length; + const graphNodes = people.map((person: PersonNode) => { + const connectionCount = relationships.filter( + (r: RelationshipEdge) => r.source === person._id || r.target === person._id + ).length; // Determine if node should be highlighted const isSelected = person._id === selectedPersonId; - const isConnected = selectedPersonId ? relationships.some(r => (r.source === selectedPersonId && r.target === person._id) || (r.target === selectedPersonId && r.source === person._id)) : false; + const isConnected = selectedPersonId + ? relationships.some( + (r: RelationshipEdge) => + (r.source === selectedPersonId && r.target === person._id) || + (r.target === selectedPersonId && r.source === person._id) + ) + : false; // Determine background color based on connection count or highlight state let bgColor; @@ -374,24 +441,36 @@ const FriendshipNetwork: React.FC = () => { }); // Create edges - const graphEdges = relationships.map(rel => { - const color = RELATIONSHIPS[rel.type as RELATIONSHIP_TYPES]?.color || RELATIONSHIPS.custom.color; - const width = rel.type === 'partner' ? 4 : rel.type === 'family' ? 3 : rel.type === 'acquaintance' ? 2 : 1; + const graphEdges = relationships.map((rel: RelationshipEdge) => { + const color = getRelationshipColor(rel.type); + const width = rel.type === 'partner' ? 4 : rel.type === 'family' ? 3 : 2; // Highlight edges connected to selected node - const isHighlighted = selectedPersonId && settings.highlightConnections && (rel.source === selectedPersonId || rel.target === selectedPersonId); + const isHighlighted = + selectedPersonId && + settings.highlightConnections && + (rel.source === selectedPersonId || rel.target === selectedPersonId); return { - id: rel._id, source: rel.source, target: rel.target, color: isHighlighted ? '#F472B6' : color, // Pink color for highlighted edges + id: rel._id, + source: rel.source, + target: rel.target, + color: isHighlighted ? '#F472B6' : color, // Pink color for highlighted edges width: isHighlighted ? width + 1 : width, // Slightly thicker for highlighted - type: rel.type, customType: rel.customType, + type: rel.type, + customType: rel.customType, }; }); - return { nodes: graphNodes, edges: graphEdges, links: [] }; + // For compatibility with CustomGraphData + return { + nodes: graphNodes, + edges: graphEdges, + links: graphEdges, // Duplicate edges as links for compatibility + }; }, [people, relationships, settings.showLabels, settings.highlightConnections, selectedPersonId]); - // Validate person form + // Form validation functions const validatePersonForm = (person: typeof newPerson): FormErrors => { const errors: FormErrors = {}; @@ -406,7 +485,6 @@ const FriendshipNetwork: React.FC = () => { return errors; }; - // Validate relationship form const validateRelationshipForm = (relationship: typeof newRelationship): FormErrors => { const errors: FormErrors = {}; @@ -428,7 +506,13 @@ const FriendshipNetwork: React.FC = () => { // Check if relationship already exists if (relationship.source && relationship.target) { - const existingRelationship = relationships.find(r => (r.source === relationship.source && r.target === relationship.target) || (relationship.bidirectional && r.source === relationship.target && r.target === relationship.source)); + const existingRelationship = relationships.find( + (r: RelationshipEdge) => + (r.source === relationship.source && r.target === relationship.target) || + (relationship.bidirectional && + r.source === relationship.target && + r.target === relationship.source) + ); if (existingRelationship) { errors.general = 'This relationship already exists'; @@ -438,7 +522,7 @@ const FriendshipNetwork: React.FC = () => { return errors; }; - // Handle person form submission + // Event handlers const handlePersonSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -460,14 +544,16 @@ const FriendshipNetwork: React.FC = () => { // Reset form and close modal setNewPerson({ - firstName: '', lastName: '', birthday: null, notes: '', + firstName: '', + lastName: '', + birthday: null, + notes: '', }); setPersonModalOpen(false); addToast('Person added successfully'); }; - // Handle person update const handleUpdatePerson = (e: React.FormEvent) => { e.preventDefault(); @@ -490,7 +576,6 @@ const FriendshipNetwork: React.FC = () => { addToast('Person updated successfully'); }; - // Handle relationship form submission const handleRelationshipSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -503,32 +588,44 @@ const FriendshipNetwork: React.FC = () => { // Create the relationship createRelationship({ - source, target, type, customType: type === 'custom' ? customType : undefined, notes, + source, + target, + type, + customType: type === 'custom' ? customType : undefined, + notes, }); // Create bidirectional relationship if selected if (bidirectional && source !== target) { createRelationship({ - source: target, target: source, type, customType: type === 'custom' ? customType : undefined, notes, + source: target, + target: source, + type, + customType: type === 'custom' ? customType : undefined, + notes, }); } // Reset form and close modal setNewRelationship({ - source: '', target: '', type: 'friend', customType: '', notes: '', bidirectional: true, + source: '', + target: '', + type: 'friend', + customType: '', + notes: '', + bidirectional: true, }); setRelationshipModalOpen(false); addToast(`Relationship${bidirectional ? 's' : ''} created successfully`); }; - // Handle deletion confirmation + // Common actions const confirmDelete = (type: string, id: string) => { setItemToDelete({ type, id }); setDeleteConfirmOpen(true); }; - // Execute deletion const executeDelete = () => { const { type, id } = itemToDelete; @@ -541,37 +638,16 @@ const FriendshipNetwork: React.FC = () => { } }; - // Open person detail modal const openPersonDetail = (person: PersonNode) => { setEditPerson({ ...person }); setPersonDetailModalOpen(true); }; - // Handle zoom controls - const handleZoomIn = () => { - setZoomLevel(prev => Math.min(prev + 0.2, 2.5)); - }; - - const handleZoomOut = () => { - setZoomLevel(prev => Math.max(prev - 0.2, 0.5)); - }; - - const handleResetZoom = () => { - setZoomLevel(1); - }; - - // Toggle sidebar - const toggleSidebar = () => { - setSidebarOpen(!sidebarOpen); - }; - - // Handle refresh network const handleRefreshNetwork = () => { refreshNetwork(); addToast('Network refreshed'); }; - // Handle node click to select and highlight const handleNodeClick = (nodeId: string) => { // Toggle selection if (selectedPersonId === nodeId) { @@ -581,14 +657,14 @@ const FriendshipNetwork: React.FC = () => { } // Open person details - const person = people.find(p => p._id === nodeId); + const person = people.find((p: PersonNode) => p._id === nodeId); if (person) { openPersonDetail(person); } }; // Sort people alphabetically - const sortedPeople = [...filteredPeople].sort((a, b) => { + const sortedPeople = [...people].sort((a: PersonNode, b: PersonNode) => { const nameA = `${a.firstName} ${a.lastName}`.toLowerCase(); const nameB = `${b.firstName} ${b.lastName}`.toLowerCase(); return nameA.localeCompare(nameB); @@ -596,18 +672,17 @@ const FriendshipNetwork: React.FC = () => { // Loading state if (loading) { - return (
-
-
-

Loading your network...

-
-
); + return ( +
+ +
+ ); } // Error state if (error) { - return (
+ return ( +

Error @@ -622,384 +697,56 @@ const FriendshipNetwork: React.FC = () => { Back to Networks

-
); +
+ ); } // Generate graph data const graphData = getGraphData(); - return (
- {/* Sidebar Toggle Button */} - - - {/* Sidebar */} -
- -
- {/* Network Header */} -
-
-

- {currentNetwork?.name || 'Relationship Network'} -

- - - -
-

Visualize your connections

-
- - {/* Network Stats */} - - - {/* Action Buttons */} -
- - -
- - {/* Sidebar Tabs */} -
- - - -
- - {/* Tab Content */} - {sidebarTab === 'overview' && (
- - -

About This Network

-

- This interactive visualization shows relationships between people in your - network. -

-
    -
  • - - Drag nodes to rearrange the network -
  • -
  • - - Click on people for more details -
  • -
  • - - Hover over connections to see relationship types -
  • -
  • - - Use the controls to zoom in/out and center the view -
  • -
-
-
- - - -

Legend

-
- {Object.entries(RELATIONSHIPS).map(([type, { label, color }]) => ( -
-
- - {RELATIONSHIPS[type]?.label} - -
))} -
-
-
- -
- - -
-
)} - - {sidebarTab === 'people' && (
-
-
- setPeopleFilter(e.target.value)} - /> - -
-
- -
- {sortedPeople.length > 0 ? (sortedPeople.map(person => { - const connectionCount = relationships.filter(r => r.source === person._id || r.target === person._id).length; - - return (
0 ? 'border-l-indigo-500' : 'border-l-slate-700'}`} - onClick={() => { - openPersonDetail(person); - setSelectedPersonId(person._id); - }} - > -
-
-

- {person.firstName} {person.lastName} -

-
- 0 ? '#60A5FA' : '#94A3B8', - }} - > - {connectionCount} connection{connectionCount !== 1 ? 's' : ''} -
-
-
- - - - - - -
-
-
); - })) : (} - action={!peopleFilter && ()} - />)} -
-
)} - - {sidebarTab === 'relations' && (
-
-
- setRelationshipFilter(e.target.value)} - /> - -
-
- -
- - {Object.entries(RELATIONSHIPS).map(([type, { label, color }]) => ())} -
- -
- {filteredRelationships.length > 0 ? (filteredRelationships.map(rel => { - const source = people.find(p => p._id === rel.source); - const target = people.find(p => p._id === rel.target); - if (!source || !target) return null; - - return (
-
-
-
- { - e.stopPropagation(); - setSelectedPersonId(rel.source); - openPersonDetail(source); - }} - > - {source.firstName} {source.lastName} - - - { - e.stopPropagation(); - setSelectedPersonId(rel.target); - const targetPerson = people.find(p => p._id === rel.target); - if (targetPerson) openPersonDetail(targetPerson); - }} - > - {target.firstName} {target.lastName} - -
-
- - - {rel.type === 'custom' ? rel.customType : RELATIONSHIPS[rel.type as RELATIONSHIP_TYPES]?.label} - -
-
-
- - - -
-
-
); - })) : (} - action={!relationshipFilter && relationshipTypeFilter === 'all' && ()} - />)} -
-
)} -
-
-
+ return ( +
+ {/* Network Sidebar Component */} + setPersonModalOpen(true)} + onAddRelationship={() => setRelationshipModalOpen(true)} + onOpenSettings={() => setSettingsModalOpen(true)} + onOpenHelp={() => setHelpModalOpen(true)} + onPersonDelete={id => confirmDelete('person', id)} + onRelationshipDelete={id => confirmDelete('relationship', id)} + onOpenPersonDetail={person => { + openPersonDetail(person); + setSelectedPersonId(person._id); + }} + onNavigateBack={() => navigate('/networks')} + /> {/* Main Graph Area */}
{graphDimensions.width <= 0 || graphDimensions.height <= 0 ? (
-
) : ( + ) : ( + { - updatePersonPosition(nodeId, { x, y }).then(); - }} - />)} + /> + )} {/* Empty state overlay */} {people.length === 0 && ( @@ -1021,10 +768,12 @@ const FriendshipNetwork: React.FC = () => { Add Your First Person
-
)} +
+ )} {/* Interaction hint */} - {people.length > 0 && interactionHint && (
0 && interactionHint && ( +
@@ -1036,660 +785,101 @@ const FriendshipNetwork: React.FC = () => { > -
)} - - {/* Graph controls */} -
- - - - - - - - - - - - -
+
+ )} {/* Quick action buttons */}
- - - - - - +
setPersonModalOpen(true)} + title="Add Person (shortcut: n)" + > + +
+
setRelationshipModalOpen(true)} + title="Add Relationship (shortcut: r)" + > + +
- {/* Add Person Modal */} - { setPersonModalOpen(false); setPersonFormErrors({}); }} - title="Add New Person" - > -
- {personFormErrors.general && ( -
- {personFormErrors.general} -
)} + formData={newPerson} + setFormData={setNewPerson} + errors={personFormErrors} + onSubmit={handlePersonSubmit} + isEdit={false} + /> - - setNewPerson({ ...newPerson, firstName: e.target.value })} - /> - - - - setNewPerson({ ...newPerson, lastName: e.target.value })} - /> - - - -
- setNewPerson({ ...newPerson, birthday: date })} - dateFormat="dd.MM.yyyy" - placeholderText="Select birthday" - className="w-full bg-slate-700 border border-slate-600 rounded-md p-2 - focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white" - showYearDropdown - dropdownMode="select" - wrapperClassName="w-full" - /> - -
-
- - -