diff --git a/frontend/src/components/NetworkGraph.jsx b/frontend/src/components/NetworkGraph.jsx index dfc0440..4774d1b 100644 --- a/frontend/src/components/NetworkGraph.jsx +++ b/frontend/src/components/NetworkGraph.jsx @@ -1,7 +1,9 @@ import React, { useRef, useEffect, useState } from 'react'; import * as d3 from 'd3'; +import { useToast } from './ToastProvider'; -const NetworkGraph = ({ contacts }) => { +const NetworkGraph = ({ contacts, onContactClick }) => { + const { addToast } = useToast(); const svgRef = useRef(); const wrapperRef = useRef(); const [viewMode, setViewMode] = useState('contacts'); @@ -21,10 +23,12 @@ const NetworkGraph = ({ contacts }) => { const links = []; + const getContactId = (c) => `contact-${c.id || c.name}`; + if (viewMode === 'contacts') { // Pass 1: Add all known contacts contacts.forEach(contact => { - const contactId = contact.id || contact.name; + const contactId = getContactId(contact); if (!nodesMap.has(contactId)) { nodesMap.set(contactId, { id: contactId, name: contact.name, group: contact.relation, radius: 25 }); } @@ -32,16 +36,16 @@ const NetworkGraph = ({ contacts }) => { // Pass 2: Establish links and identify existing connectors contacts.forEach(contact => { - const contactId = contact.id || contact.name; + const contactId = getContactId(contact); if (contact.connected_by) { const connectorName = contact.connected_by.trim(); - let connectorId = connectorName; + let connectorId = `connector-${connectorName.toLowerCase()}`; // Match existing contact by name (case-insensitive) to avoid duplicates const existingContact = contacts.find(c => c.name && c.name.toLowerCase() === connectorName.toLowerCase()); if (existingContact) { - connectorId = existingContact.id || existingContact.name; + connectorId = getContactId(existingContact); } if (!nodesMap.has(connectorId)) { @@ -49,22 +53,55 @@ const NetworkGraph = ({ contacts }) => { nodesMap.set(connectorId, { id: connectorId, name: connectorName, group: 'Connector', radius: 20 }); links.push({ source: 'Me', target: connectorId }); } - links.push({ source: connectorId, target: contactId }); + + if (connectorId !== contactId) { + links.push({ source: connectorId, target: contactId }); + } } else { links.push({ source: 'Me', target: contactId }); } }); } else { // viewMode === 'companies' + + // First count company connections to scale size + const companyConnections = {}; + contacts.forEach(contact => { + const companyName = contact.company ? contact.company.trim() : 'Independent'; + const cKey = companyName.toLowerCase(); + companyConnections[cKey] = (companyConnections[cKey] || 0) + 1; + }); + + const counts = Object.values(companyConnections); + const minConn = counts.length > 0 ? Math.min(...counts) : 0; + const maxConn = counts.length > 0 ? Math.max(...counts) : 0; + + const MIN_RADIUS = 32; + const MAX_RADIUS = 80; + + const getCompanyRadius = (count) => { + if (maxConn === minConn) return MIN_RADIUS; + return MIN_RADIUS + (Math.sqrt(count - minConn) / Math.sqrt(maxConn - minConn)) * (MAX_RADIUS - MIN_RADIUS); + }; + contacts.forEach(contact => { - const contactId = contact.id || contact.name; + const contactId = getContactId(contact); const companyName = contact.company ? contact.company.trim() : 'Independent'; - const companyId = `company-${companyName}`; + const cKey = companyName.toLowerCase(); + const companyId = `company-${cKey}`; // Add Company Node if not exists if (!nodesMap.has(companyId)) { - nodesMap.set(companyId, { id: companyId, name: companyName, group: 'Company', radius: 32 }); + nodesMap.set(companyId, { + id: companyId, + name: companyName, // preserve original casing for display + group: 'Company', + radius: getCompanyRadius(companyConnections[cKey]), + link: contact.link + }); links.push({ source: 'Me', target: companyId }); + } else if (contact.link && !nodesMap.get(companyId).link) { + nodesMap.get(companyId).link = contact.link; } // Add Contact Node @@ -90,6 +127,7 @@ const NetworkGraph = ({ contacts }) => { const zoom = d3.zoom() .scaleExtent([0.1, 4]) + .extent([[0, 0], [width, height]]) .on("zoom", (event) => { g.attr("transform", event.transform); }); @@ -99,11 +137,39 @@ const NetworkGraph = ({ contacts }) => { const g = svg.append("g"); const simulation = d3.forceSimulation(nodes) - .force("link", d3.forceLink(links).id(d => d.id).distance(150)) - .force("charge", d3.forceManyBody().strength(-400)) + .force("link", d3.forceLink(links).id(d => d.id).distance(viewMode === 'companies' ? 120 : 80)) + .force("charge", d3.forceManyBody().strength(-250)) .force("center", d3.forceCenter(width / 2, height / 2)) .force("collide", d3.forceCollide().radius(d => d.radius + 15)); + // Fast-forward simulation to pre-calculate layout + simulation.stop(); + for (let i = 0; i < 300; ++i) simulation.tick(); + + // Calculate graph bounds + const minX = d3.min(nodes, d => d.x - d.radius) || 0; + const maxX = d3.max(nodes, d => d.x + d.radius) || width; + const minY = d3.min(nodes, d => d.y - d.radius) || 0; + const maxY = d3.max(nodes, d => d.y + d.radius) || height; + + const graphWidth = maxX - minX; + const graphHeight = maxY - minY; + const midX = minX + graphWidth / 2; + const midY = minY + graphHeight / 2; + + // Auto-scale to fit within screen with padding + const padding = 80; + const scale = Math.max(0.05, Math.min(1.5, Math.min( + (width - padding) / (graphWidth || 1), + (height - padding) / (graphHeight || 1) + ))); + + // Apply the calculated zoom to the SVG + svg.call(zoom.transform, d3.zoomIdentity.translate(width / 2 - midX * scale, height / 2 - midY * scale).scale(scale)); + + // Restart with low alpha to allow drag interactions and organic settling + simulation.alpha(0.15).restart(); + // Create gradient definitions const defs = svg.append("defs"); @@ -154,7 +220,22 @@ const NetworkGraph = ({ contacts }) => { .call(drag(simulation)) .style("cursor", "pointer") .on("mouseover", function() { d3.select(this).select("circle").attr("stroke", "#1890ff").attr("stroke-width", 3); }) - .on("mouseout", function() { d3.select(this).select("circle").attr("stroke", "#fff").attr("stroke-width", 2); }); + .on("mouseout", function() { d3.select(this).select("circle").attr("stroke", "#fff").attr("stroke-width", 2); }) + .on("click", (event, d) => { + if (event.defaultPrevented) return; // Prevent triggering on drag + if (d.group === 'Company') { + if (d.link) { + window.open(d.link, '_blank', 'noopener,noreferrer'); + } else { + addToast(`No careers link available for ${d.name}`, 'info'); + } + } else if (d.group !== 'me' && d.group !== 'Connector' && onContactClick) { + const fullContact = contacts.find(c => getContactId(c) === d.id); + if (fullContact) { + onContactClick(fullContact); + } + } + }); node.append("circle") .attr("r", d => d.radius) @@ -204,7 +285,7 @@ const NetworkGraph = ({ contacts }) => { .on("end", dragended); } - }, [contacts, viewMode]); + }, [contacts, viewMode, addToast, onContactClick]); return (
diff --git a/frontend/src/components/__tests__/NetworkGraph.test.jsx b/frontend/src/components/__tests__/NetworkGraph.test.jsx index 72aedae..2db5d6e 100644 --- a/frontend/src/components/__tests__/NetworkGraph.test.jsx +++ b/frontend/src/components/__tests__/NetworkGraph.test.jsx @@ -1,7 +1,11 @@ import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import NetworkGraph from '../NetworkGraph'; +vi.mock('../ToastProvider', () => ({ + useToast: () => ({ addToast: vi.fn() }) +})); + describe('NetworkGraph', () => { beforeEach(() => { // Mock clientWidth/Height for D3 rendering in JSDOM diff --git a/frontend/src/pages/NetworkPage.jsx b/frontend/src/pages/NetworkPage.jsx index c13567c..8497ac0 100644 --- a/frontend/src/pages/NetworkPage.jsx +++ b/frontend/src/pages/NetworkPage.jsx @@ -15,6 +15,7 @@ const NetworkPage = () => { const [showModal, setShowModal] = useState(false); const [editingId, setEditingId] = useState(null); const [showMap, setShowMap] = useState(false); + const [selectedContact, setSelectedContact] = useState(null); // 3. Local state for the form inputs const [newContact, setNewContact] = useState({ @@ -90,6 +91,50 @@ const NetworkPage = () => { } }; + const renderContactCard = (contact) => ( +
+
+ + +
+
{getInitials(contact.name)}
+
+
{contact.name}
+
+ {contact.link ? ( + {contact.company} + ) : ( + contact.company + )} +
+
{contact.relation}
+
+ {contact.email && ( +
+ Email: {contact.email} +
+ )} + {contact.phone &&
Phone: {contact.phone}
} + {contact.linkedin && ( +
+ + + + + LinkedIn + +
+ )} + {contact.connected_by &&
Connected by: {contact.connected_by}
} +
+ ); + if (loading) return ; return ( @@ -114,106 +159,72 @@ const NetworkPage = () => { {uploadStatus &&
{uploadStatus}
} -
-
- - - - - - setSearchTerm(e.target.value)} - style={{ width: '100%', margin: 0, paddingLeft: '40px', paddingRight: '15px', borderRadius: '10px', border: '1px solid var(--border, #eaeaea)', height: '44px', outline: 'none', transition: 'border-color 0.2s, box-shadow 0.2s', fontSize: '0.95rem' }} - onFocus={(e) => { e.target.style.borderColor = '#007bff'; e.target.style.boxShadow = '0 0 0 3px rgba(0,123,255,0.1)'; }} - onBlur={(e) => { e.target.style.borderColor = 'var(--border, #eaeaea)'; e.target.style.boxShadow = 'none'; }} - /> -
- -
- + {!showMap && ( +
+
+ + + + + + setSearchTerm(e.target.value)} + style={{ width: '100%', margin: 0, paddingLeft: '40px', paddingRight: '15px', borderRadius: '10px', border: '1px solid var(--border, #eaeaea)', height: '44px', outline: 'none', transition: 'border-color 0.2s, box-shadow 0.2s', fontSize: '0.95rem' }} + onFocus={(e) => { e.target.style.borderColor = '#007bff'; e.target.style.boxShadow = '0 0 0 3px rgba(0,123,255,0.1)'; }} + onBlur={(e) => { e.target.style.borderColor = 'var(--border, #eaeaea)'; e.target.style.boxShadow = 'none'; }} + /> +
- +
+ + + +
-
+ )} {showMap ? ( - + ) : (
- {displayedContacts.map(contact => ( -
-
- - -
-
{getInitials(contact.name)}
-
-
{contact.name}
-
- {contact.link ? ( - {contact.company} - ) : ( - contact.company - )} -
-
{contact.relation}
-
- {contact.email && ( -
- Email: {contact.email} -
- )} - {contact.phone &&
Phone: {contact.phone}
} - {contact.linkedin && ( - - )} - {contact.connected_by &&
Connected by: {contact.connected_by}
} -
- ))} + {displayedContacts.map(contact => renderContactCard(contact))}
)} @@ -313,6 +324,18 @@ const NetworkPage = () => {
)} + + {/* Contact Details Modal (for graph clicks) */} + {selectedContact && ( +
setSelectedContact(null)}> +
e.stopPropagation()} style={{ background: 'transparent', boxShadow: 'none' }}> +
+ + {renderContactCard(selectedContact)} +
+
+
+ )} ); };