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 (