org-chart / src /App.js
Aaron Xavier (FLW)
Dynamic interaction
d97a923
Raw
History Blame Contribute Delete
20.8 kB
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 (
<div style={{ height: '100vh', width: '100vw', overflow: 'hidden', background: 'var(--light-gray)', color: 'var(--dark-gray)' }}>
<div style={{ position: 'fixed', top: 0, left: 0, height: '100vh', width: 180, background: 'var(--light-gray)', boxShadow: '2px 0 8px #0001', zIndex: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 40 }}>
<h2 style={{ marginBottom: 40, fontWeight: 700, letterSpacing: 1, color: 'var(--main-green)' }}>Organigram</h2>
<button onClick={() => setMode('hierarchy')} style={{ width: '90%', margin: '10px 0', padding: '12px 0', border: 'none', borderRadius: 6, background: mode === 'hierarchy' ? 'var(--main-green)' : 'var(--white)', color: mode === 'hierarchy' ? 'var(--white)' : 'var(--dark-gray)', fontWeight: 600, fontSize: 16, cursor: 'pointer', boxShadow: mode === 'hierarchy' ? '0 2px 8px #7ba80033' : 'none', borderLeft: mode === 'hierarchy' ? '6px solid var(--secondary-green)' : '6px solid transparent', transition: 'background 0.2s, color 0.2s' }}>Hierarchy</button>
<button onClick={() => setMode('projects')} style={{ width: '90%', margin: '10px 0', padding: '12px 0', border: 'none', borderRadius: 6, background: mode === 'projects' ? 'var(--main-green)' : 'var(--white)', color: mode === 'projects' ? 'var(--white)' : 'var(--dark-gray', fontWeight: 600, fontSize: 16, cursor: 'pointer', boxShadow: mode === 'projects' ? '0 2px 8px #7ba80033' : 'none', borderLeft: mode === 'projects' ? '6px solid var(--secondary-green)' : '6px solid transparent', transition: 'background 0.2s, color 0.2s' }}>Projects</button>
<button onClick={() => setMode('technologies')} style={{ width: '90%', margin: '10px 0', padding: '12px 0', border: 'none', borderRadius: 6, background: mode === 'technologies' ? 'var(--main-green)' : 'var(--white)', color: mode === 'technologies' ? 'var(--white)' : 'var(--dark-gray)', fontWeight: 600, fontSize: 16, cursor: 'pointer', boxShadow: mode === 'technologies' ? '0 2px 8px #7ba80033' : 'none', borderLeft: mode === 'technologies' ? '6px solid var(--secondary-green)' : '6px solid transparent', transition: 'background 0.2s, color 0.2s' }}>Technologies</button>
</div>
<svg ref={svgRef} style={{ position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', zIndex: 0, background: 'var(--white)' }}></svg>
{/* TU Dortmund/FLW logo bottom right */}
<img
src="/images/logo.png"
alt="TU Dortmund/FLW Logo"
style={{
position: 'fixed',
right: 24,
bottom: 24,
width: 120,
height: 'auto',
zIndex: 10,
background: 'none',
borderRadius: 0,
boxShadow: 'none',
objectFit: 'contain',
border: 'none',
pointerEvents: 'none',
}}
/>
</div>
);
}
export default App;