Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 94 additions & 13 deletions frontend/src/components/NetworkGraph.jsx
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -21,50 +23,85 @@ 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 });
}
});

// 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)) {
// Create an intermediary node for the connector if it doesn't exist
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
Expand All @@ -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);
});
Expand All @@ -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");

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -204,7 +285,7 @@ const NetworkGraph = ({ contacts }) => {
.on("end", dragended);
}

}, [contacts, viewMode]);
}, [contacts, viewMode, addToast, onContactClick]);

return (
<div ref={wrapperRef} style={{ width: '100%', height: '600px', position: 'relative', marginTop: '20px' }}>
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/__tests__/NetworkGraph.test.jsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading