Spaces:
Running
Running
| 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; | |