diff --git a/frontend/package.json b/frontend/package.json index 130fee7..8c447ef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,15 +13,16 @@ "license": "ISC", "description": "", "dependencies": { + "@headlessui/react": "^2.2.1", "@tailwindcss/vite": "^4.1.4", "axios": "^1.8.4", "framer-motion": "^12.7.3", "react": "^19.1.0", + "react-datepicker": "^8.3.0", "react-dom": "^19.1.0", "react-force-graph-2d": "^1.27.1", "react-icons": "^5.5.0", "react-router-dom": "^7.5.0", - "tailwindcss": "^4.1.4", "ts-node": "^10.9.2", "typescript": "^5.8.3", "vite": "^6.2.6" @@ -33,7 +34,10 @@ "@types/react-dom": "^19.1.2", "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^4.4.0", + "autoprefixer": "^10.4.21", + "postcss": "^8.4.32", "prettier": "^3.5.3", + "tailwindcss": "^4.1.4", "webpack": "^5.99.5", "webpack-cli": "^6.0.1" } diff --git a/frontend/src/app.css b/frontend/src/app.css index a461c50..d4b5078 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1 +1 @@ -@import "tailwindcss"; \ No newline at end of file +@import 'tailwindcss'; diff --git a/frontend/src/components/CanvasGraph.tsx b/frontend/src/components/CanvasGraph.tsx new file mode 100644 index 0000000..5bc6fd4 --- /dev/null +++ b/frontend/src/components/CanvasGraph.tsx @@ -0,0 +1,709 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; + +// Define types for graph elements +interface NodeData { + id: string; + firstName: string; + lastName: string; + connectionCount: number; + bgColor: string; + x: number; + y: number; +} + +interface EdgeData { + id: string; + source: string; + target: string; + color: string; + width: number; +} + +interface GraphData { + nodes: NodeData[]; + edges: EdgeData[]; +} + +interface CanvasGraphProps { + data: GraphData; + width: number; + height: number; +} + +// Physics constants +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 +const DAMPING = 0.6; // Damping factor for velocity (0-1) +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 }) => { + const canvasRef = useRef(null); + + // State for interactions + const [draggedNode, setDraggedNode] = useState(null); + const [hoveredNode, setHoveredNode] = useState(null); + const [offsetX, setOffsetX] = useState(0); + const [offsetY, setOffsetY] = useState(0); + const [isPanning, setIsPanning] = useState(false); + const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); + const [panStart, setPanStart] = useState({ x: 0, y: 0 }); + const [scale, setScale] = useState(1); + const [autoLayout, setAutoLayout] = useState(true); + + // Node physics state + const [nodePositions, setNodePositions] = useState< + Record< + string, + { + x: number; + y: number; + vx: number; + vy: number; + } + > + >({}); + + // 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) + ); + + if (allNodesHavePositions) { + console.log('All nodes already have positions'); + 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 && + nodePositions[node.id].y !== 0 + ) { + initialPositions[node.id] = { + ...nodePositions[node.id], + vx: 0, + vy: 0, + }; + 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; + + initialPositions[node.id] = { + x: x, + y: y, + 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) { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = null; + } + return; + } + + const simulatePhysics = () => { + 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 + + // 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; + + forceX += (dx / distance) * repulsionForce; + forceY += (dy / distance) * repulsionForce; + } + }); + + // Find connected nodes (neighbors) for the current node + const connectedNodeIds = new Set(); + data.edges.forEach(edge => { + if (edge.source === node.id) { + connectedNodeIds.add(edge.target); + } else if (edge.target === node.id) { + connectedNodeIds.add(edge.source); + } + }); + + // Attraction forces (only to connected nodes) + connectedNodeIds.forEach(targetId => { + if (!newPositions[targetId]) return; + + const dx = newPositions[targetId].x - newPositions[node.id].x; + 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 + const normalizedDistance = distance / MAX_DISTANCE; + const attractionForce = ATTRACTION_STRENGTH * normalizedDistance; + + forceX += (dx / distance) * attractionForce; + forceY += (dy / distance) * attractionForce; + } + }); + + // 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 + ); + 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 + } + if (newPositions[node.id].x > width - padding) { + newPositions[node.id].x = width - padding; + newPositions[node.id].vx *= -0.5; + } + if (newPositions[node.id].y < padding) { + newPositions[node.id].y = padding; + newPositions[node.id].vy *= -0.5; + } + if (newPositions[node.id].y > height - padding) { + newPositions[node.id].y = height - padding; + newPositions[node.id].vy *= -0.5; + } + }); + + return newPositions; + }); + + animationRef.current = requestAnimationFrame(simulatePhysics); + }; + + animationRef.current = requestAnimationFrame(simulatePhysics); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = null; + } + }; + }, [data.nodes, data.edges, width, height, autoLayout, draggedNode]); // FIX: Added proper dependencies + + // 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; + } + } + + return null; + }, + [data.nodes, nodePositions, panOffset, scale] + ); // FIX: Added proper dependencies + + // 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]: { + ...prev[nodeId], + vx: 0, + vy: 0, + }, + })); + } else { + // Start panning + setIsPanning(true); + setPanStart({ x, y }); + } + }, + [findNodeAtPosition, nodePositions, panOffset, scale] + ); // FIX: Added proper dependencies + + 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]: { + ...prev[draggedNode], + x: transformedX - offsetX, + y: transformedY - offsetY, + vx: 0, + vy: 0, + }, + })); + } + // Handle panning + else if (isPanning) { + const dx = x - panStart.x; + const dy = y - panStart.y; + setPanOffset(prev => ({ + x: prev.x + dx, + y: prev.y + dy, + })); + setPanStart({ x, y }); + } + }, + [findNodeAtPosition, draggedNode, isPanning, offsetX, offsetY, panOffset, panStart, scale] + ); // FIX: Added proper dependencies + + const handleMouseUp = useCallback(() => { + // End any drag or pan operation + setDraggedNode(null); + setIsPanning(false); + }, []); + + const handleWheel = useCallback( + (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] + ); + + 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); + + ctx.fillStyle = autoLayout ? '#4ade80' : '#cbd5e1'; + ctx.font = '16px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(autoLayout ? 'Physics: ON' : 'Physics: OFF', width - 70, 40); + }, + [autoLayout, width] + ); + + // Draw function - FIX: Properly memoized with all dependencies + const drawGraph = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + 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); + + if (sourceNode && targetNode) { + const sourcePos = nodePositions[sourceNode.id]; + const targetPos = nodePositions[targetNode.id]; + + if (sourcePos && targetPos) { + ctx.beginPath(); + 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.lineWidth = highlighted ? (edge.width ? edge.width + 1 : 3) : edge.width || 1; + + ctx.stroke(); + } + } + }); + + // 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 + } else if (isHovered) { + ctx.fillStyle = '#3b82f6'; // Blue for hovered node + } else { + ctx.fillStyle = node.bgColor || '#475569'; // Default to slate-600 + } + ctx.fill(); + + // Draw border + ctx.shadowColor = 'transparent'; + ctx.shadowBlur = 0; + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Draw initials + const initials = `${node.firstName.charAt(0)}${node.lastName.charAt(0)}`; + ctx.fillStyle = 'white'; + 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; + const padding = 6; + + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect( + pos.x - textWidth / 2 - padding, + pos.y + NODE_RADIUS + 5, + textWidth + padding * 2, + textHeight + padding * 2 + ); + + ctx.fillStyle = 'white'; + ctx.fillText(fullName, pos.x, pos.y + NODE_RADIUS + 15 + padding); + } + }); + + // Restore canvas transformation + ctx.restore(); + + // Draw UI controls + drawControls(ctx); + }, [ + data, + nodePositions, + hoveredNode, + draggedNode, + scale, + panOffset, + 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 + + // FIX: Ensure continuous rendering with requestAnimationFrame + useEffect(() => { + // Create a continuous rendering loop that doesn't depend on physics updates + let animationFrameId: number; + + const renderLoop = () => { + drawGraph(); + animationFrameId = requestAnimationFrame(renderLoop); + }; + + // Start the render loop + animationFrameId = requestAnimationFrame(renderLoop); + + // Clean up + return () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + }; + }, [drawGraph]); + + // Get cursor style based on current state + const getCursorStyle = useCallback(() => { + if (draggedNode) return 'grabbing'; + if (hoveredNode) return 'grab'; + if (isPanning) return 'move'; + 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.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'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('Initializing graph...', width / 2, height / 2); + } + } + } + }, [width, height, data.nodes.length, nodePositions]); + + return ( +
+ +
+ ); +}; + +export default CanvasGraph; diff --git a/frontend/src/components/FriendshipNetwork.tsx b/frontend/src/components/FriendshipNetwork.tsx index bd5045a..6ff75d4 100644 --- a/frontend/src/components/FriendshipNetwork.tsx +++ b/frontend/src/components/FriendshipNetwork.tsx @@ -1,20 +1,141 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useCallback, useRef, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useFriendshipNetwork } from '../hooks/useFriendshipNetwork'; import { useNetworks } from '../context/NetworkContext'; -import { motion, AnimatePresence } from 'framer-motion'; -import ForceGraph2D from 'react-force-graph-2d'; -import { - FaUserPlus, FaUserFriends, FaTrash, FaTimes, FaCog, - FaSearch, FaSearchPlus, FaSearchMinus, FaRedo, FaCompress +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import { Transition } from '@headlessui/react'; +import { + FaUserPlus, + FaUserFriends, + FaTrash, + FaEdit, + FaRedo, + FaCompress, + FaSearchPlus, + FaSearchMinus, + FaTimes, + FaChevronLeft, + FaChevronRight, + FaRegCalendarAlt, + FaInfo, + FaFilter, + FaPalette, + FaSave, + FaUserCircle, + FaSearch, + FaCog, + FaHome, + FaArrowLeft, + FaNetworkWired, + FaPlus, + FaStar, + FaExclamationTriangle, } from 'react-icons/fa'; +// Import custom UI components +import { + Tooltip, + Modal, + ConfirmDialog, + NetworkStats, + Toast, + Button, + FormField, + Badge, + EmptyState, + Card, + CardBody, + ToastItem, +} from './FriendshipNetworkComponents'; + +// Import visible canvas graph component +import CanvasGraph from './CanvasGraph'; + +// Define types +type RelationshipType = 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom'; + +interface PersonNode { + _id: string; + firstName: string; + lastName: string; + birthday?: Date | string | null; + notes?: string; + position?: { + x: number; + y: number; + }; +} + +interface RelationshipEdge { + _id: string; + source: string; + target: string; + type: RelationshipType; + customType?: string; + notes?: string; +} + +interface CanvasGraphProps { + data: { + nodes: { + id: string; + firstName: string; + lastName: string; + connectionCount: number; + bgColor: string; + x: number; + y: number; + showLabel: boolean; + }[]; + edges: { + id: string; + source: string; + target: string; + color: string; + width: number; + type: RelationshipType; + customType?: string; + }[]; + }; + width: number; + height: number; + zoomLevel: number; + onNodeClick: (nodeId: string) => void; + onNodeDrag: (nodeId: string, x: number, y: number) => void; +} + +// Type for form errors +interface FormErrors { + [key: string]: string; +} + +// Graph appearance constants +const RELATIONSHIP_COLORS = { + freund: '#60A5FA', // Light blue + partner: '#F472B6', // Pink + familie: '#34D399', // Green + arbeitskolleg: '#FBBF24', // Yellow + custom: '#9CA3AF', // Gray +}; + +const RELATIONSHIP_LABELS = { + freund: 'Friend', + partner: 'Partner', + familie: 'Family', + arbeitskolleg: 'Colleague', + custom: 'Custom', +}; + +// Main FriendshipNetwork component const FriendshipNetwork: React.FC = () => { const { id } = useParams<{ id: string }>(); const { networks } = useNetworks(); const navigate = useNavigate(); - const graphRef = useRef(null); + const graphContainerRef = useRef(null); + const [graphDimensions, setGraphDimensions] = useState({ width: 0, height: 0 }); + // Network data state from custom hook const { people, relationships, @@ -24,928 +145,1850 @@ const FriendshipNetwork: React.FC = () => { updatePerson, deletePerson, createRelationship, - updateRelationship, deleteRelationship, refreshNetwork, - } = useFriendshipNetwork(id || null); + updatePersonPosition: updatePersonPositionImpl = ( + id: string, + position: { x: number; y: number } + ) => { + console.warn('updatePersonPosition not implemented'); + return Promise.resolve(); + }, + } = useFriendshipNetwork(id || null) as any; - // Local state for the UI + // 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 [activeTab, setActiveTab] = useState('add'); + 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: '', + birthday: null as Date | null, + notes: '', }); + + const [editPerson, setEditPerson] = useState(null); + const [newRelationship, setNewRelationship] = useState({ source: '', - targets: [] as string[], - type: 'freund', + target: '', + type: 'freund' as RelationshipType, customType: '', + notes: '', + bidirectional: true, }); - const [showOverrideModal, setShowOverrideModal] = useState(false); - const [overrideRelationship, setOverrideRelationship] = useState(null); - const [graphSettings, setGraphSettings] = useState({ - chargeStrength: -150, - linkDistance: 100, - collideRadius: 50, - velocityDecay: 0.4, + + // 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, - nodeSize: 20, + animationSpeed: 'medium', + highlightConnections: true, + nodeSize: 'medium', }); - const [showSettings, setShowSettings] = useState(false); - const [graphData, setGraphData] = useState({ nodes: [], links: [] }); - + + // Selected person state for highlighting + const [selectedPersonId, setSelectedPersonId] = useState(null); + // Get current network info const currentNetwork = networks.find(network => network._id === id); - // Redirect if network not found + // Effect for graph container dimensions useEffect(() => { - if (!loading && !currentNetwork && networks.length > 0) { - navigate('/networks'); - } - }, [currentNetwork, networks, loading, navigate]); + if (!graphContainerRef.current) return; - // Update graph data when people or relationships change + const updateDimensions = () => { + if (graphContainerRef.current) { + const { width, height } = graphContainerRef.current.getBoundingClientRect(); + + setGraphDimensions(prev => { + if (prev.width !== width || prev.height !== height) { + return { width, height }; + } + return prev; + }); + } + }; + + // Initial measurement + updateDimensions(); + + // Set up resize observer + const resizeObserver = new ResizeObserver(updateDimensions); + if (graphContainerRef.current) { + resizeObserver.observe(graphContainerRef.current); + } + + // Set up window resize listener + window.addEventListener('resize', updateDimensions); + + // Clean up + return () => { + if (graphContainerRef.current) { + resizeObserver.unobserve(graphContainerRef.current); + } + window.removeEventListener('resize', updateDimensions); + }; + }, []); + + // Update dimensions when sidebar is toggled useEffect(() => { - if (people && relationships) { - const nodes = people.map(person => ({ - id: person.id, - nodeId: person._id, + const timeoutId = setTimeout(() => { + if (graphContainerRef.current) { + const { width, height } = graphContainerRef.current.getBoundingClientRect(); + setGraphDimensions({ width, height }); + } + }, 300); + + return () => clearTimeout(timeoutId); + }, [sidebarOpen]); + + // Dismiss interaction hint after 10 seconds + useEffect(() => { + if (interactionHint) { + const timer = setTimeout(() => { + setInteractionHint(false); + }, 10000); + return () => clearTimeout(timer); + } + }, [interactionHint]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Only apply shortcuts when not in an input field + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; + + // Ctrl/Cmd + / to open help modal + if ((e.ctrlKey || e.metaKey) && e.key === '/') { + e.preventDefault(); + setHelpModalOpen(true); + } + + // + for zoom in + if (e.key === '+' || e.key === '=') { + e.preventDefault(); + handleZoomIn(); + } + + // - for zoom out + if (e.key === '-' || e.key === '_') { + e.preventDefault(); + handleZoomOut(); + } + + // 0 for reset zoom + if (e.key === '0') { + e.preventDefault(); + handleResetZoom(); + } + + // n for new person + if (e.key === 'n' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + setPersonModalOpen(true); + } + + // r for new relationship + if (e.key === 'r' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + setRelationshipModalOpen(true); + } + + // s for toggle sidebar + if (e.key === 's' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + + // 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 = '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; + const index = totalNodes; + + if (totalNodes <= 0) { + return { x: centerX, y: centerY }; + } else if (totalNodes <= 4) { + 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), + }; + } 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 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), + }; + } else { + const clusterCount = Math.max(3, Math.floor(Math.sqrt(totalNodes))); + const clusterIndex = index % clusterCount; + + const clusterAngle = (clusterIndex / clusterCount) * 2 * Math.PI; + const clusterDistance = maxRadius * 0.6; + const clusterX = centerX + clusterDistance * Math.cos(clusterAngle); + const clusterY = centerY + clusterDistance * Math.sin(clusterAngle); + + const clusterRadius = maxRadius * 0.3; + const randomAngle = Math.random() * 2 * Math.PI; + const randomDistance = Math.random() * clusterRadius; + + return { + x: clusterX + randomDistance * Math.cos(randomAngle), + y: clusterY + randomDistance * Math.sin(randomAngle), + }; + } + }, [graphDimensions.width, graphDimensions.height, people.length]); + + // Transform API data to graph format + const getGraphData = useCallback(() => { + if (!people || !relationships) { + return { nodes: [], edges: [] }; + } + + // Create nodes + const graphNodes = people.map(person => { + const connectionCount = relationships.filter( + r => 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; + + // Determine background color based on connection count or highlight state + let bgColor; + if (isSelected) { + bgColor = '#F472B6'; // Pink-400 for selected + } else if (isConnected && settings.highlightConnections) { + bgColor = '#A78BFA'; // Violet-400 for connected + } else if (connectionCount === 0) { + bgColor = '#94A3B8'; // Slate-400 + } else if (connectionCount === 1) { + bgColor = '#38BDF8'; // Sky-400 + } else if (connectionCount <= 3) { + bgColor = '#818CF8'; // Indigo-400 + } else if (connectionCount <= 5) { + bgColor = '#A78BFA'; // Violet-400 + } else { + bgColor = '#F472B6'; // Pink-400 + } + + return { + id: person._id, firstName: person.firstName, lastName: person.lastName, - label: `${person.firstName} ${person.lastName.charAt(0)}.`, - birthday: person.birthday, - x: person.position?.x, - y: person.position?.y, - // Dynamic size based on connection count - val: 1 + relationships.filter(r => r.source === person.id || r.target === person.id).length * 0.5 - })); - - const links = relationships.map(rel => { - // Different colors for different relationship types - let color = '#9CA3AF'; // Default gray - if (rel.type === 'freund') color = '#3B82F6'; // Blue - if (rel.type === 'partner') color = '#EC4899'; // Pink - if (rel.type === 'familie') color = '#10B981'; // Green - if (rel.type === 'arbeitskolleg') color = '#F59E0B'; // Yellow - - return { - source: rel.source, - target: rel.target, - id: rel.id, - relId: rel._id, - type: rel.type, - color, - // Visual elements - value: rel.type === 'partner' ? 3 : rel.type === 'familie' ? 2 : 1, - }; - }); - - setGraphData({ nodes, links }); - } - }, [people, relationships]); - - // Save node positions when they are dragged - const handleNodeDragEnd = (node: any) => { - if (node && node.x && node.y && node.nodeId) { - updatePerson(node.nodeId, { - position: { x: node.x, y: node.y } - }); - } - }; - - // Add a new person to the network - const handleAddPerson = async () => { - if (newPerson.firstName.trim() === '' || newPerson.lastName.trim() === '') { - alert('Please enter both first and last name'); - return; - } - - try { - await createPerson({ - firstName: newPerson.firstName.trim(), - lastName: newPerson.lastName.trim(), - birthday: newPerson.birthday || undefined, - position: { - // Generate a random position within the viewport - x: 100 + Math.random() * 400, - y: 100 + Math.random() * 300, - }, - }); - - setNewPerson({ - firstName: '', - lastName: '', - birthday: '', - }); - } catch (error) { - console.error('Error adding person:', error); - alert('Failed to add person.'); - } - }; - - // Add new relationships between source person and multiple target people - const handleAddRelationship = async () => { - const { source, targets, type, customType } = newRelationship; - - if (source === '' || targets.length === 0) { - alert('Please select source and at least one target person'); - return; - } - - const actualType = type === 'custom' ? customType.trim() : type; - - if (type === 'custom' && customType.trim() === '') { - alert('Please enter a custom relationship type'); - return; - } - - // Check if any relationships already exist - const existingRelationships: any[] = []; - targets.forEach(target => { - if (source !== target) { - const existingEdge = relationships.find( - edge => - (edge.source === source && edge.target === target) || - (edge.source === target && edge.target === source) - ); - - if (existingEdge) { - existingRelationships.push({ - source, - target, - existingType: existingEdge.type, - newType: actualType, - edgeId: existingEdge.id, - }); - } - } + connectionCount, + bgColor, + x: person.position?.x || 0, + y: person.position?.y || 0, + showLabel: settings.showLabels, + }; }); - if (existingRelationships.length > 0) { - // Show override modal - setOverrideRelationship({ - existingRelationships, - newRelationships: targets - .filter( - target => source !== target && !existingRelationships.some(rel => rel.target === target) - ) - .map(target => ({ source, target, type: actualType })), + // Create edges + const graphEdges = relationships.map(rel => { + const color = RELATIONSHIP_COLORS[rel.type] || RELATIONSHIP_COLORS.custom; + const width = rel.type === 'partner' ? 4 : rel.type === 'familie' ? 3 : 2; + + // Highlight edges connected to selected node + 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 + width: isHighlighted ? width + 1 : width, // Slightly thicker for highlighted + type: rel.type, + customType: rel.customType, + }; + }); + + return { nodes: graphNodes, edges: graphEdges }; + }, [people, relationships, settings.showLabels, settings.highlightConnections, selectedPersonId]); + + // Validate person form + const validatePersonForm = (person: typeof newPerson): FormErrors => { + const errors: FormErrors = {}; + + if (!person.firstName.trim()) { + errors.firstName = 'First name is required'; + } + + if (!person.lastName.trim()) { + errors.lastName = 'Last name is required'; + } + + return errors; + }; + + // Validate relationship form + const validateRelationshipForm = (relationship: typeof newRelationship): FormErrors => { + const errors: FormErrors = {}; + + if (!relationship.source) { + errors.source = 'Source person is required'; + } + + if (!relationship.target) { + errors.target = 'Target person is required'; + } + + if (relationship.source === relationship.target) { + errors.target = 'Source and target cannot be the same person'; + } + + if (relationship.type === 'custom' && !relationship.customType.trim()) { + errors.customType = 'Custom relationship type is required'; + } + + // 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) + ); + + if (existingRelationship) { + errors.general = 'This relationship already exists'; + } + } + + return errors; + }; + + // Handle person form submission + const handlePersonSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const errors = validatePersonForm(newPerson); + setPersonFormErrors(errors); + + if (Object.keys(errors).length > 0) return; + + // Create person with smart positioning + const position = getSmartNodePosition(); + + createPerson({ + firstName: newPerson.firstName.trim(), + lastName: newPerson.lastName.trim(), + birthday: newPerson.birthday?.toISOString() || undefined, + notes: newPerson.notes, + position, + }); + + // Reset form and close modal + setNewPerson({ + firstName: '', + lastName: '', + birthday: null, + notes: '', + }); + + setPersonModalOpen(false); + addToast('Person added successfully'); + }; + + // Handle person update + const handleUpdatePerson = (e: React.FormEvent) => { + e.preventDefault(); + + if (!editPerson) return; + + const errors = validatePersonForm(editPerson as any); + setPersonFormErrors(errors); + + if (Object.keys(errors).length > 0) return; + + updatePerson(editPerson._id, { + firstName: editPerson.firstName, + lastName: editPerson.lastName, + birthday: editPerson.birthday ? new Date(editPerson.birthday).toISOString() : undefined, + notes: editPerson.notes, + }); + + setEditPerson(null); + setPersonDetailModalOpen(false); + addToast('Person updated successfully'); + }; + + // Handle relationship form submission + const handleRelationshipSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const errors = validateRelationshipForm(newRelationship); + setRelationshipFormErrors(errors); + + if (Object.keys(errors).length > 0) return; + + const { source, target, type, customType, notes, bidirectional } = newRelationship; + + // Create the relationship + createRelationship({ + 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, }); - setShowOverrideModal(true); - return; } - // Process each target for new relationships - const addPromises = targets - .map(target => { - if (source !== target) { - return createRelationship({ - source, - target, - type: type as any, - customType: type === 'custom' ? customType : undefined, - }); - } - return Promise.resolve(); - }) - .filter(Boolean); + // Reset form and close modal + setNewRelationship({ + source: '', + target: '', + type: 'freund', + customType: '', + notes: '', + bidirectional: true, + }); - if (addPromises.length === 0) { - alert('No valid relationships to add.'); - return; - } + setRelationshipModalOpen(false); + addToast(`Relationship${bidirectional ? 's' : ''} created successfully`); + }; - try { - await Promise.all(addPromises); - setNewRelationship({ source: '', targets: [], type: 'freund', customType: '' }); - } catch (error) { - console.error('Error adding relationships:', error); - alert('Failed to add one or more relationships.'); + // Handle deletion confirmation + const confirmDelete = (type: string, id: string) => { + setItemToDelete({ type, id }); + setDeleteConfirmOpen(true); + }; + + // Execute deletion + const executeDelete = () => { + const { type, id } = itemToDelete; + + if (type === 'person') { + deletePerson(id); + addToast('Person deleted'); + } else if (type === 'relationship') { + deleteRelationship(id); + addToast('Relationship deleted'); } }; - // Handle confirming relationship overrides - const handleConfirmOverride = async () => { - if (!overrideRelationship) return; - - const { existingRelationships, newRelationships } = overrideRelationship; - - try { - // Remove existing relationships that will be overridden - await Promise.all(existingRelationships.map(rel => deleteRelationship(rel.edgeId))); - - // Add new overridden relationships - await Promise.all( - existingRelationships.map(rel => - createRelationship({ - source: rel.source, - target: rel.target, - type: rel.newType as any, - customType: rel.newType === 'custom' ? rel.customType : undefined, - }) - ) - ); - - // Add completely new relationships - await Promise.all( - newRelationships.map(rel => - createRelationship({ - source: rel.source, - target: rel.target, - type: rel.type as any, - customType: rel.type === 'custom' ? rel.customType : undefined, - }) - ) - ); - - setShowOverrideModal(false); - setOverrideRelationship(null); - setNewRelationship({ source: '', targets: [], type: 'freund', customType: '' }); - } catch (error) { - console.error('Error overriding relationships:', error); - alert('Failed to override relationships.'); - } + // Open person detail modal + const openPersonDetail = (person: PersonNode) => { + setEditPerson({ ...person }); + setPersonDetailModalOpen(true); }; - // Handle canceling relationship overrides - const handleCancelOverride = async () => { - // If there are new relationships that don't need overrides, add those - if (overrideRelationship && overrideRelationship.newRelationships.length > 0) { - try { - await Promise.all( - overrideRelationship.newRelationships.map(rel => - createRelationship({ - source: rel.source, - target: rel.target, - type: rel.type as any, - customType: rel.type === 'custom' ? rel.customType : undefined, - }) - ) - ); - } catch (error) { - console.error('Error adding new relationships:', error); - } - } - - setShowOverrideModal(false); - setOverrideRelationship(null); + // Handle zoom controls + const handleZoomIn = () => { + setZoomLevel(prev => Math.min(prev + 0.2, 2.5)); }; - // Handle multiple selections in the targets dropdown - const handleTargetChange = (e: React.ChangeEvent) => { - const selectedOptions = Array.from(e.target.selectedOptions, option => option.value); - setNewRelationship({ ...newRelationship, targets: selectedOptions }); + const handleZoomOut = () => { + setZoomLevel(prev => Math.max(prev - 0.2, 0.5)); }; - // Delete a node and its associated edges - const handleDeleteNode = async (id: string) => { - if ( - window.confirm( - 'Are you sure you want to delete this person? All their relationships will also be deleted.' - ) - ) { - try { - await deletePerson(id); - } catch (error) { - console.error('Error deleting person:', error); - alert('Failed to delete person.'); - } - } + const handleResetZoom = () => { + setZoomLevel(1); }; - // Get relationship type label - const getRelationshipLabel = (type: string) => { - switch (type) { - case 'freund': - return 'Freund/in'; - case 'partner': - return 'Partner/in'; - case 'familie': - return 'Familie/Verwandschaft'; - case 'arbeitskolleg': - return 'Arbeitskolleg/innen'; - default: - return type; - } - }; - - // Remove a relationship between two people - const handleRemoveRelationship = async (edgeId: string) => { - try { - await deleteRelationship(edgeId); - } catch (error) { - console.error('Error removing relationship:', error); - alert('Failed to remove relationship.'); - } - }; - - // Graph control functions - const zoomIn = () => { - if (graphRef.current) { - graphRef.current.zoom(1.2); - } - }; - - const zoomOut = () => { - if (graphRef.current) { - graphRef.current.zoom(0.8); - } - }; - - const centerGraph = () => { - if (graphRef.current) { - graphRef.current.zoomToFit(400); - } - }; - - const refreshGraph = () => { - refreshNetwork(); - }; - - // Toggle sidebar visibility + // Toggle sidebar const toggleSidebar = () => { setSidebarOpen(!sidebarOpen); }; - // Handle physics settings change - const handleSettingChange = (setting: string, value: number | boolean) => { - setGraphSettings(prev => ({ - ...prev, - [setting]: value - })); + // 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) { + setSelectedPersonId(null); + } else { + setSelectedPersonId(nodeId); + } + + // Open person details + const person = people.find(p => p._id === nodeId); + if (person) { + openPersonDetail(person); + } + }; + + // Sort people alphabetically + const sortedPeople = [...filteredPeople].sort((a, b) => { + const nameA = `${a.firstName} ${a.lastName}`.toLowerCase(); + const nameB = `${b.firstName} ${b.lastName}`.toLowerCase(); + return nameA.localeCompare(nameB); + }); + + // Loading state if (loading) { return (
-
+
+
+

Loading your network...

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

Error

-

{error}

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

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

- - {/* Tabs */} -
- - - +
+ +
+ {/* Network Header */} +
+
+

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

+ + +
+

Visualize your connections

+
- {activeTab === 'add' && ( - <> - {/* Add Person Form */} -
-

- Add Person -

-
- setNewPerson({ ...newPerson, firstName: e.target.value })} - /> - setNewPerson({ ...newPerson, lastName: e.target.value })} - /> - setNewPerson({ ...newPerson, birthday: e.target.value })} - /> - + {/* 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(RELATIONSHIP_COLORS).map(([type, color]) => ( +
+
+ + {RELATIONSHIP_LABELS[type as RelationshipType]} + +
+ ))}
+
+
+ +
+ + +
+
+ )} + + {sidebarTab === 'people' && ( +
+
+
+ setPeopleFilter(e.target.value)} + /> +
+
- {/* Add Relationship Form */} -
-

- Add Relationship -

-
- +
+ {sortedPeople.length > 0 ? ( + sortedPeople.map(person => { + const connectionCount = relationships.filter( + r => r.source === person._id || r.target === person._id + ).length; - -

Hold Ctrl/Cmd to select multiple people

- - - - {newRelationship.type === 'custom' && ( - - setNewRelationship({ ...newRelationship, customType: e.target.value }) - } - /> - )} - - -
-
- - )} - - {activeTab === 'view' && ( - <> - {/* People List */} -
-

- People ({people.length}) -

- {people.length > 0 ? ( -
- {people.map(node => ( -
- {node.firstName} {node.lastName} - -
- ))} -
- ) : ( -

- No people added yet. -

- )} -
- - {/* Relationships List */} -
-

- Relationships ({relationships.length}) -

- {relationships.length > 0 ? ( -
- {relationships.map(edge => { - const source = people.find(n => n.id === edge.source); - const target = people.find(n => n.id === edge.target); - if (!source || !target) return null; - - return ( -
-
-
- {source.firstName} {source.lastName.charAt(0)}. - - {target.firstName} {target.lastName.charAt(0)}. -
- -
-
- {getRelationshipLabel(edge.type)} + 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' : ''}
- ); - })} -
- ) : ( -

- No relationships added yet. -

- )} -
- - )} - - {activeTab === 'settings' && ( -
-

Graph Physics Settings

- -
- - handleSettingChange('chargeStrength', parseInt(e.target.value))} - className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer" +
+ + + + + + +
+
+
+ ); + }) + ) : ( + } + action={ + !peopleFilter && ( + + ) + } /> -

Stronger negative values push nodes apart more

-
- -
- - handleSettingChange('linkDistance', parseInt(e.target.value))} - className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer" - /> -

Higher values increase distance between connected nodes

-
- -
- - handleSettingChange('collideRadius', parseInt(e.target.value))} - className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer" - /> -

Higher values prevent node overlap more

-
- -
- - handleSettingChange('velocityDecay', parseFloat(e.target.value))} - className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer" - /> -

Higher values make the graph settle faster

-
- -
- - handleSettingChange('nodeSize', parseInt(e.target.value))} - className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer" - /> -
- -
- handleSettingChange('showLabels', e.target.checked)} - className="w-4 h-4 rounded bg-slate-700 border-slate-600 focus:ring-indigo-500 focus:ring-2" - /> - -
- - + )}
- )} -
- - )} - +
+ )} + + {sidebarTab === 'relations' && ( +
+
+
+ setRelationshipFilter(e.target.value)} + /> + +
+
+ +
+ + {Object.entries(RELATIONSHIP_COLORS).map(([type, 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 + : RELATIONSHIP_LABELS[rel.type]} + +
+
+
+ + + +
+
+
+ ); + }) + ) : ( + } + action={ + !relationshipFilter && + relationshipTypeFilter === 'all' && ( + + ) + } + /> + )} +
+
+ )} +
+ +
{/* Main Graph Area */} -
-
- `${node.firstName} ${node.lastName}`} - nodeRelSize={graphSettings.nodeSize} - nodeVal={node => node.val * graphSettings.nodeSize / 10} - nodeColor={node => { - // Different colors for different node types or connections - const connCount = graphData.links.filter( - (link: any) => link.source.id === node.id || link.target.id === node.id - ).length; - - // Color scales from blue to purple based on number of connections - return connCount === 0 ? '#4B5563' : // Gray for isolated nodes - connCount < 3 ? '#3B82F6' : // Blue for few connections - connCount < 6 ? '#8B5CF6' : // Indigo for moderate - '#A855F7'; // Purple for many - }} - linkWidth={link => link.value} - linkColor={link => link.color} - nodeCanvasObjectMode={() => graphSettings.showLabels ? 'after' : undefined} - nodeCanvasObject={(node, ctx, globalScale) => { - if (!graphSettings.showLabels) return; - - const label = node.label; - const fontSize = 12/globalScale; - ctx.font = `${fontSize}px Sans-Serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; - - // Draw background for better readability - const textWidth = ctx.measureText(label).width; - ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; - ctx.fillRect( - node.x - textWidth/2 - 2, - node.y + graphSettings.nodeSize/globalScale + 2, - textWidth + 4, - fontSize + 2 - ); - - // Draw text - ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; - ctx.fillText( - label, - node.x, - node.y + graphSettings.nodeSize/globalScale + fontSize/2 + 3 - ); - }} - linkDirectionalParticles={3} - linkDirectionalParticleWidth={link => link.value} - linkDirectionalParticleSpeed={0.005} - d3AlphaDecay={0.02} - d3VelocityDecay={graphSettings.velocityDecay} - cooldownTicks={100} - onNodeDragEnd={handleNodeDragEnd} - // Physics settings - d3Force={(forceName, force) => { - if (forceName === 'charge') { - force.strength(graphSettings.chargeStrength); - } - if (forceName === 'link') { - force.distance(graphSettings.linkDistance); - } - if (forceName === 'collide') { - force.radius(graphSettings.collideRadius); - } - }} - enableNodeDrag={true} - enableZoomInteraction={true} - enablePanInteraction={true} - onNodeClick={(node) => { - // Center view on node when clicked - if (graphRef.current) { - graphRef.current.centerAt(node.x, node.y, 1000); - graphRef.current.zoom(1.5, 1000); - } +
+ {graphDimensions.width <= 0 || graphDimensions.height <= 0 ? ( +
+
+
+ ) : ( + { + updatePersonPosition(nodeId, { x, y }); }} /> + )} + + {/* Empty state overlay */} + {people.length === 0 && ( +
+
+
+ +
+

Start Building Your Network

+

+ Add people and create relationships between them to visualize your network +

+ +
+
+ )} + + {/* Interaction hint */} + {people.length > 0 && interactionHint && ( +
+ + Click on a person to see details, drag to reposition + +
+ )} + + {/* Graph controls */} +
+ + + + + + + + + + + +
- {/* Graph Controls */} -
- - - - -
- - {/* Mobile Controls */} -
- + {/* Quick action buttons */} +
+ + + + + +
- {/* Override Confirmation Modal */} - - {showOverrideModal && overrideRelationship && ( - - { + setPersonModalOpen(false); + setPersonFormErrors({}); + }} + title="Add New Person" + > +
+ {personFormErrors.general && ( +
+ {personFormErrors.general} +
+ )} + + + setNewPerson({ ...newPerson, firstName: e.target.value })} + /> + + + + setNewPerson({ ...newPerson, lastName: e.target.value })} + /> + + + +
+ setNewPerson({ ...newPerson, birthday: date })} + dateFormat="MMMM d, 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" + /> + +
+
+ + +