import React, { useEffect, useRef, useState } from 'react'; import * as XLSX from 'xlsx'; import * as d3 from 'd3'; import './App.css'; function App() { const [people, setPeople] = useState([]); const [mode, setMode] = useState('hierarchy'); const [strength, setStrength] = useState(0.5); const svgRef = useRef(); const [projectNodes, setProjectNodes] = useState([]); // Add state for focused person const [focusedPerson, setFocusedPerson] = useState(null); useEffect(() => { fetch('/people.xlsx') .then(res => res.arrayBuffer()) .then(data => { const workbook = XLSX.read(data, { type: 'array' }); const sheet = workbook.Sheets[workbook.SheetNames[0]]; const json = XLSX.utils.sheet_to_json(sheet); setPeople(json); }); }, []); useEffect(() => { if (!people.length) return; if (mode !== 'projects') return; // Build project nodes const projectSet = new Set(); people.forEach(p => (p.Projects || '').split(';').forEach(proj => proj && projectSet.add(proj.trim()))); setProjectNodes(Array.from(projectSet).map(p => ({ id: p, type: 'project' }))); }, [people, mode]); useEffect(() => { if (!people.length) return; const width = window.innerWidth; const height = window.innerHeight; const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); svg.attr('width', width).attr('height', height) .style('display', 'block') .style('background', '#fff'); // Add zoom and pan const zoom = d3.zoom() .scaleExtent([0.2, 3]) .on('zoom', (event) => { svg.select('g.viz').attr('transform', event.transform); }); svg.call(zoom); // Create a group for all viz elements const g = svg.append('g').attr('class', 'viz'); if (mode === 'projects' || mode === 'technologies') { // --- Central node mode (projects or technologies) --- const key = mode === 'projects' ? 'Projects' : 'Technologies'; const color = mode === 'projects' ? '#fbb4ae' : '#b2df8a'; // 1. Create nodes: central (project/tech), people (orbiting) const centralSet = new Set(); people.forEach(p => (p[key] || '').split(';').forEach(val => val && centralSet.add(val.trim()))); const centrals = Array.from(centralSet).map(p => ({ id: p, type: 'central' })); const nodes = [ ...centrals, ...people.map(p => ({ ...p, type: 'person' })) ]; // 2. Create links: person <-> central const links = []; people.forEach(person => { (person[key] || '').split(';').forEach(val => { val = val.trim(); if (!val) return; links.push({ source: val, target: person.Name, label: val }); }); }); // 3. D3 simulation const simulation = d3.forceSimulation(nodes) // Repel all nodes by default .force('charge', d3.forceManyBody().strength(d => d.type === 'central' ? -500 * strength : -200 * strength)) // More repulsion // Center the layout .force('center', d3.forceCenter(width / 2, height / 2)) // Only attract people to centrals via links .force('link', d3.forceLink(links) .id(d => d.type === 'central' ? d.id : d.Name) .distance(250) .strength(strength * 0.5) ) // Weaker attraction, longer distance // Prevent overlap .force('collide', d3.forceCollide().radius(d => d.type === 'central' ? 75 : 65)); // Draw links const link = g.append('g') .attr('stroke', 'var(--secondary-green)') .attr('stroke-width', 2) .selectAll('line') .data(links) .enter().append('line'); // Draw link labels const linkLabels = g.append('g') .selectAll('text') .data(links) .enter().append('text') .attr('font-size', 12) .attr('fill', 'var(--dark-gray)') .attr('text-anchor', 'middle') .text(d => d.label); // Draw nodes const node = g.selectAll('g.node') .data(nodes) .enter().append('g') .attr('class', 'node') .call(d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended)); node.append('circle') .attr('r', d => d.type === 'central' ? 55 : 45) .attr('fill', d => d.type === 'central' ? 'var(--main-green)' : 'var(--secondary-green)') .attr('stroke', d => d.type === 'central' ? 'var(--secondary-green)' : 'var(--main-green)') .attr('stroke-width', 3); node.filter(d => d.type === 'person').append('image') .attr('xlink:href', d => `/images/${d.Image}`) .attr('x', -25) .attr('y', -25) .attr('width', 50) .attr('height', 50) .attr('clip-path', 'circle(25px at 25px 25px)'); node.append('text') .attr('y', d => d.type === 'central' ? 5 : 55) .attr('text-anchor', 'middle') .attr('font-weight', d => d.type === 'central' ? 'bold' : 'normal') .attr('fill', d => d.type === 'central' ? 'var(--white)' : 'var(--dark-gray)') .text(d => d.type === 'central' ? d.id : d.Name); node.filter(d => d.type === 'person').append('title') .text(d => `Projects: ${d.Projects}\nTech: ${d.Technologies}\nHobbies: ${d.Hobbies}`); simulation.on('tick', () => { link .attr('x1', d => getNode(d.source).x) .attr('y1', d => getNode(d.source).y) .attr('x2', d => getNode(d.target).x) .attr('y2', d => getNode(d.target).y); linkLabels .attr('x', d => (getNode(d.source).x + getNode(d.target).x) / 2) .attr('y', d => (getNode(d.source).y + getNode(d.target).y) / 2 - 10) .text(d => d.label); node.attr('transform', d => `translate(${d.x},${d.y})`); }); function getNode(n) { if (typeof n === 'string') return nodes.find(nd => nd.id === n || nd.Name === n) || {}; return n; } function dragstarted(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; } function dragged(event, d) { d.fx = event.x; d.fy = event.y; } function dragended(event, d) { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; } return; } // --- D3 force simulation for hierarchy --- // Only define links for non-central modes let links = []; function dragstarted(event, d) { if (!event.active) { d.fx = d.x; d.fy = d.y; } d3.select(this).raise(); } function dragged(event, d) { d.fx = event.x; d.fy = event.y; } function dragended(event, d) { if (!event.active) { d.fx = null; d.fy = null; } } if (mode === 'hierarchy') { // Improved hierarchy: CEO -> Project Leaders, Project Leaders -> Developers in their projects const ceo = people.find(p => p.Hierarchy === 'CEO'); // Project leaders: CTO, Manager const leaders = people.filter(p => p.Hierarchy === 'CTO' || p.Hierarchy === 'Manager'); // 1. CEO to project leaders (no link label) leaders.forEach(leader => { if (ceo) links.push({ source: ceo, target: leader }); }); // 2. Project leaders to their developers (if a dev shares a project with a leader, connect) const developers = people.filter(p => p.Hierarchy === 'Developer'); leaders.forEach(leader => { const leaderProjects = (leader.Projects || '').split(';').map(p => p.trim()); developers.forEach(dev => { const devProjects = (dev.Projects || '').split(';').map(p => p.trim()); // If any project overlaps, connect if (leaderProjects.some(proj => devProjects.includes(proj))) { links.push({ source: leader, target: dev, label: '' }); } }); }); // --- D3 force simulation --- const simulation = d3.forceSimulation(people) .force('charge', d3.forceManyBody().strength(-300 * strength)) // Increased repulsion .force('center', d3.forceCenter(width / 2, height / 2)) .force('link', d3.forceLink(links).id(d => d.Name).distance(250).strength(strength * 0.5)) // Decreased attraction, longer distance .force('collide', d3.forceCollide().radius(65)) // Strong gravity to bottom for developers .force('y', d3.forceY(d => { if (d.Hierarchy === 'Developer') return height - 100; if (d.Hierarchy === 'CTO' || d.Hierarchy === 'Manager') return height / 2; return 100; }).strength(0.5)); const link = g.append('g') .attr('stroke', 'var(--secondary-green)') .attr('stroke-width', 2) .selectAll('line') .data(links) .enter().append('line'); // Remove link labels for CEO to leaders const linkLabels = g.append('g') .selectAll('text') .data(links.filter(l => l.label)) .enter().append('text') .attr('font-size', 12) .attr('fill', 'var(--dark-gray)') .attr('text-anchor', 'middle') .text(d => d.label); const node = g.selectAll('g.node') .data(people) .enter().append('g') .attr('class', 'node') .call(d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended)); node.append('circle') .attr('r', 50) .attr('fill', d => d.Hierarchy === 'CEO' ? 'var(--main-green)' : d.Hierarchy === 'CTO' || d.Hierarchy === 'Manager' ? 'var(--secondary-green)' : 'var(--white)') .attr('stroke', d => d.Hierarchy === 'Developer' ? 'var(--main-green)' : 'var(--secondary-green)') .attr('stroke-width', 3); node.append('image') .attr('xlink:href', d => `/images/${d.Image}`) .attr('x', -30) .attr('y', -30) .attr('width', 60) .attr('height', 60) .attr('clip-path', 'circle(30px at 30px 30px)'); node.append('text') .attr('y', 60) .attr('text-anchor', 'middle') .attr('font-weight', d => d.Hierarchy === 'CEO' ? 'bold' : 'normal') .attr('fill', d => d.Hierarchy === 'CEO' ? '#111' : 'var(--dark-gray)') .text(d => d.Name); node.append('title') .text(d => `Projects: ${d.Projects}\nTech: ${d.Technologies}\nHobbies: ${d.Hobbies}`); simulation.on('tick', () => { link .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); linkLabels .attr('x', d => (d.source.x + d.target.x) / 2) .attr('y', d => (d.source.y + d.target.y) / 2 - 10) .text(d => d.label); node.attr('transform', d => `translate(${d.x},${d.y})`); }); return; } else if (mode === 'technologies' || mode === 'projects') { // handled above } else { // Link people sharing technologies (fallback) const techMap = {}; people.forEach(p => { (p.Technologies || '').split(';').forEach(tech => { tech = tech.trim(); if (!tech) return; if (!techMap[tech]) techMap[tech] = []; techMap[tech].push(p); }); }); Object.entries(techMap).forEach(([tech, arr]) => { for (let i = 0; i < arr.length; ++i) { for (let j = i + 1; j < arr.length; ++j) { links.push({ source: arr[i], target: arr[j], label: tech }); } } }); } if (mode === 'hierarchy') { const simulation = d3.forceSimulation(people) .force('charge', d3.forceManyBody().strength(-300 * strength)) // Increased repulsion .force('center', d3.forceCenter(width / 2, height / 2)) .force('link', d3.forceLink(links).id(d => d.Name).distance(250).strength(strength * 0.5)) // Decreased attraction, longer distance .force('collide', d3.forceCollide().radius(65)); const link = g.append('g') .attr('stroke', 'var(--secondary-green)') .attr('stroke-width', 2) .selectAll('line') .data(links) .enter().append('line'); const linkLabels = g.append('g') .selectAll('text') .data(links) .enter().append('text') .attr('font-size', 12) .attr('fill', 'var(--dark-gray)') .attr('text-anchor', 'middle') .text(d => d.label); const node = g.selectAll('g.node') .data(people) .enter().append('g') .attr('class', 'node') .call(d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended)); node.append('circle') .attr('r', 50) .attr('fill', '#b3cde0'); node.append('image') .attr('xlink:href', d => `/images/${d.Image}`) .attr('x', -30) .attr('y', -30) .attr('width', 60) .attr('height', 60); node.append('text') .attr('y', 60) .attr('text-anchor', 'middle') .text(d => d.Name); node.append('title') .text(d => `Projects: ${d.Projects}\nTech: ${d.Technologies}\nHobbies: ${d.Hobbies}`); simulation.on('tick', () => { link .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); linkLabels .attr('x', d => (d.source.x + d.target.x) / 2) .attr('y', d => (d.source.y + d.target.y) / 2 - 10) .text(d => d.label); node.attr('transform', d => `translate(${d.x},${d.y})`); }); } // Add event listeners for person bubbles (change from hover to click) if (mode === 'hierarchy' || mode === 'projects' || mode === 'technologies') { d3.selectAll('g.node').each(function(d) { if (d.type === 'person' || d.Hierarchy) { d3.select(this) .on('click', function(event, datum) { if (focusedPerson && focusedPerson === datum) { setFocusedPerson(null); } else { setFocusedPerson(datum); } }); } }); } }, [people, mode]); // Draw focused person overlay (SVG overlay, not JSX) useEffect(() => { if (!focusedPerson || !svgRef.current) return; const svg = d3.select(svgRef.current); // Remove any previous overlay svg.selectAll('g.focus-overlay').remove(); // Overlay group const overlay = svg.append('g').attr('class', 'focus-overlay'); // Zoomed circle overlay.append('circle') .attr('cx', focusedPerson.x) .attr('cy', focusedPerson.y) .attr('r', 90) .attr('fill', 'var(--main-green)') .attr('stroke', 'var(--secondary-green)') .attr('stroke-width', 6) .style('filter', 'drop-shadow(0 0 16px #0006)'); // Image overlay.append('image') .attr('xlink:href', `/images/${focusedPerson.Image}`) .attr('x', focusedPerson.x - 50) .attr('y', focusedPerson.y - 50) .attr('width', 100) .attr('height', 100) .attr('clip-path', 'circle(50px at 50px 50px)'); // Concentric circles overlay.append('circle').attr('cx', focusedPerson.x).attr('cy', focusedPerson.y).attr('r', 120).attr('fill', 'none').attr('stroke', '#fbb4ae').attr('stroke-width', 3); overlay.append('circle').attr('cx', focusedPerson.x).attr('cy', focusedPerson.y).attr('r', 150).attr('fill', 'none').attr('stroke', '#b2df8a').attr('stroke-width', 3); overlay.append('circle').attr('cx', focusedPerson.x).attr('cy', focusedPerson.y).attr('r', 180).attr('fill', 'none').attr('stroke', '#b3cde0').attr('stroke-width', 3); // Projects if (focusedPerson.Projects) { const arr = focusedPerson.Projects.split(';').map(p => p.trim()).filter(Boolean); arr.forEach((proj, i) => { overlay.append('text') .attr('x', focusedPerson.x + 120 * Math.cos((2 * Math.PI * i) / arr.length - Math.PI/2)) .attr('y', focusedPerson.y + 120 * Math.sin((2 * Math.PI * i) / arr.length - Math.PI/2)) .attr('text-anchor', 'middle') .attr('alignment-baseline', 'middle') .attr('font-size', 16) .attr('fill', '#fbb4ae') .attr('font-weight', 'bold') .text(proj); }); } // Technologies if (focusedPerson.Technologies) { const arr = focusedPerson.Technologies.split(';').map(t => t.trim()).filter(Boolean); arr.forEach((tech, i) => { overlay.append('text') .attr('x', focusedPerson.x + 150 * Math.cos((2 * Math.PI * i) / arr.length - Math.PI/2)) .attr('y', focusedPerson.y + 150 * Math.sin((2 * Math.PI * i) / arr.length - Math.PI/2)) .attr('text-anchor', 'middle') .attr('alignment-baseline', 'middle') .attr('font-size', 15) .attr('fill', '#b2df8a') .attr('font-weight', 'bold') .text(tech); }); } // Hobbies if (focusedPerson.Hobbies) { const arr = focusedPerson.Hobbies.split(';').map(h => h.trim()).filter(Boolean); arr.forEach((hobby, i) => { overlay.append('text') .attr('x', focusedPerson.x + 180 * Math.cos((2 * Math.PI * i) / arr.length - Math.PI/2)) .attr('y', focusedPerson.y + 180 * Math.sin((2 * Math.PI * i) / arr.length - Math.PI/2)) .attr('text-anchor', 'middle') .attr('alignment-baseline', 'middle') .attr('font-size', 14) .attr('fill', '#b3cde0') .attr('font-weight', 'bold') .text(hobby); }); } return () => { svg.selectAll('g.focus-overlay').remove(); }; }, [focusedPerson]); return (

Organigram

{/* TU Dortmund/FLW logo bottom right */} TU Dortmund/FLW Logo
); } export default App;