Created
February 28, 2025 23:55
-
-
Save PatrickFanella/3936f93da4231ea334a728aef4f86dc7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>D3 Org Chart</title> | |
<script src="https://d3js.org/d3.v7.min.js"></script> | |
<style> | |
body { | |
font-family: Arial, sans-serif; | |
margin: 0; | |
padding: 0; | |
overflow: hidden; | |
width: 100vw; | |
height: 100vh; | |
} | |
#chart-container { | |
width: 100%; | |
height: 100vh; | |
position: relative; | |
} | |
#chart { | |
width: 100%; | |
height: 100%; | |
} | |
#sidebar { | |
width: 400px; | |
min-width: 200px; | |
max-width: 800px; | |
background: #f5f5f5; | |
border-left: 1px solid #ddd; | |
height: 100vh; | |
box-shadow: -2px 0 5px rgba(0,0,0,0.1); | |
position: fixed; | |
right: 0; | |
top: 0; | |
transform: translateX(100%); | |
transition: transform 0.3s ease; | |
display: flex; | |
flex-direction: column; | |
z-index: 1000; | |
} | |
#sidebar.visible { | |
transform: translateX(0); | |
} | |
#sidebar-resizer { | |
position: absolute; | |
left: 0; | |
top: 0; | |
bottom: 0; | |
width: 4px; | |
cursor: ew-resize; | |
background: transparent; | |
} | |
#sidebar-resizer:hover { | |
background: rgba(0, 0, 0, 0.1); | |
} | |
#close-sidebar { | |
position: absolute; | |
top: 20px; | |
right: 20px; | |
cursor: pointer; | |
font-size: 20px; | |
color: #666; | |
background: none; | |
border: none; | |
padding: 0; | |
z-index: 2; | |
} | |
#close-sidebar:hover { | |
color: #333; | |
} | |
#sidebar-content { | |
padding: 20px; | |
height: 100%; | |
display: flex; | |
flex-direction: column; | |
} | |
#sidebarTitle { | |
margin-top: 0; | |
margin-bottom: 20px; | |
padding-right: 30px; | |
position: sticky; | |
top: 0; | |
background: #f5f5f5; | |
padding-top: 20px; | |
z-index: 1; | |
} | |
#sidebarContent { | |
flex: 1; | |
overflow-y: auto; | |
padding-right: 10px; | |
} | |
/* Style the scrollbar */ | |
#sidebarContent::-webkit-scrollbar { | |
width: 8px; | |
} | |
#sidebarContent::-webkit-scrollbar-track { | |
background: #f1f1f1; | |
border-radius: 4px; | |
} | |
#sidebarContent::-webkit-scrollbar-thumb { | |
background: #888; | |
border-radius: 4px; | |
} | |
#sidebarContent::-webkit-scrollbar-thumb:hover { | |
background: #555; | |
} | |
/* Table styles */ | |
table { | |
width: 100%; | |
border-collapse: collapse; | |
margin-top: 10px; | |
} | |
th, td { | |
padding: 12px 8px; | |
text-align: left; | |
border-bottom: 1px solid #ddd; | |
} | |
th { | |
background-color: #fff; | |
position: sticky; | |
top: 0; | |
z-index: 1; | |
} | |
/* Prevent zoom controls from being covered by sidebar */ | |
.zoom-controls { | |
position: fixed; | |
bottom: 20px; | |
left: 20px; | |
z-index: 1000; | |
background: white; | |
padding: 5px; | |
border-radius: 5px; | |
box-shadow: 0 2px 5px rgba(0,0,0,0.1); | |
} | |
.zoom-controls button { | |
padding: 5px 10px; | |
margin: 0 5px; | |
cursor: pointer; | |
} | |
.node { | |
cursor: pointer; | |
} | |
.node rect { | |
fill: #fff; | |
stroke: #999; | |
stroke-width: 1.5px; | |
} | |
.node:hover rect { | |
fill: #f0f0f0; | |
} | |
.node.selected rect { | |
stroke: #2196F3; | |
stroke-width: 2px; | |
} | |
.node text { | |
font-size: 14px; | |
} | |
.link { | |
fill: none; | |
stroke: #999; | |
stroke-width: 1.5px; | |
} | |
.text-muted { | |
color: #666; | |
font-size: 0.85em; | |
} | |
td { | |
vertical-align: top; | |
} | |
table { | |
table-layout: fixed; | |
} | |
th:nth-child(1) { width: 20%; } | |
th:nth-child(2) { width: 25%; } | |
th:nth-child(3) { width: 15%; } | |
th:nth-child(4) { width: 40%; } | |
#search-container { | |
margin: 10px 0; | |
padding: 0 0 10px 0; | |
position: sticky; | |
top: 60px; | |
background: #f5f5f5; | |
z-index: 1; | |
} | |
#employeeSearch { | |
width: 100%; | |
padding: 8px; | |
border: 1px solid #ddd; | |
border-radius: 4px; | |
font-size: 14px; | |
box-sizing: border-box; | |
} | |
#employeeSearch:focus { | |
outline: none; | |
border-color: #2196F3; | |
box-shadow: 0 0 3px rgba(33, 150, 243, 0.3); | |
} | |
tr.hidden { | |
display: none; | |
} | |
.highlight { | |
background-color: yellow; | |
padding: 2px; | |
border-radius: 2px; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="chart-container"> | |
<div id="chart"></div> | |
<div class="zoom-controls"> | |
<button onclick="zoomIn()">+</button> | |
<button onclick="zoomOut()">-</button> | |
<button onclick="resetZoom()">Reset</button> | |
</div> | |
</div> | |
<div id="sidebar"> | |
<div id="sidebar-resizer"></div> | |
<button id="close-sidebar" onclick="closeSidebar()">×</button> | |
<div id="sidebar-content"> | |
<h3 id="sidebarTitle">Select a department</h3> | |
<div id="search-container"> | |
<input | |
type="text" | |
id="employeeSearch" | |
placeholder="Search employees..." | |
onkeyup="filterEmployees()" | |
> | |
</div> | |
<div id="sidebarContent"></div> | |
</div> | |
</div> | |
<script src="org_chart_data.js"></script> | |
<script> | |
// Add logging to check the data structure | |
console.log("Loaded data:", data); | |
// Helper function to count total employees in a node and its children | |
function countTotalEmployees(node) { | |
let count = node.employees ? node.employees.length : 0; | |
if (node.children) { | |
node.children.forEach(child => { | |
count += countTotalEmployees(child); | |
}); | |
} | |
return count; | |
} | |
// Set up the dimensions with adjusted spacing | |
const width = 1200; // Increased width | |
const height = 800; | |
const nodeWidth = 220; // Slightly wider nodes | |
const nodeHeight = 100; | |
// Create the tree layout with adjusted spacing | |
const tree = d3.tree() | |
.nodeSize([nodeWidth, nodeHeight * 2]); // More vertical space between nodes | |
// Add zoom behavior with adjusted limits | |
const zoom = d3.zoom() | |
.scaleExtent([0.1, 2]) // Allow zooming out further | |
.on("zoom", (event) => { | |
svg.attr("transform", event.transform); | |
}); | |
// Create the SVG container | |
const svgContainer = d3.select("#chart") | |
.append("svg") | |
.attr("width", width) | |
.attr("height", height) | |
.call(zoom); | |
// Add initial transform to center the chart | |
const svg = svgContainer | |
.append("g") | |
.attr("transform", `translate(${width / 2}, ${nodeHeight})`); | |
// Create the hierarchy and log it | |
const root = d3.hierarchy(data); | |
console.log("Hierarchy:", root); | |
// Generate the tree layout | |
tree(root); | |
// Draw the links | |
svg.selectAll(".link") | |
.data(root.links()) | |
.join("path") | |
.attr("class", "link") | |
.attr("d", d3.linkVertical() | |
.x(d => d.x) | |
.y(d => d.y)); | |
// Update nodes with employee counts | |
const node = svg.selectAll(".node") | |
.data(root.descendants()) | |
.join("g") | |
.attr("class", "node") | |
.attr("transform", d => `translate(${d.x},${d.y})`); | |
// Add rectangles for nodes | |
node.append("rect") | |
.attr("width", nodeWidth) | |
.attr("height", nodeHeight) | |
.attr("x", -nodeWidth / 2) | |
.attr("y", -nodeHeight / 2) | |
.attr("rx", 5) | |
.on("click", showEmployeeDetails); | |
// Add text with employee counts | |
node.append("text") | |
.attr("dy", "0") | |
.attr("y", 0) | |
.attr("x", 0) | |
.attr("text-anchor", "middle") | |
.text(d => `${d.data.name} (${countTotalEmployees(d.data)})`); | |
function getAllEmployeesFromNode(node) { | |
let employees = []; | |
// Add direct employees if they exist | |
if (node.employees && node.employees.length > 0) { | |
employees = employees.concat( | |
node.employees.map(emp => ({ | |
...emp, | |
office: node.name | |
})) | |
); | |
} | |
// Recursively get employees from children | |
if (node.children) { | |
node.children.forEach(child => { | |
employees = employees.concat(getAllEmployeesFromNode(child)); | |
}); | |
} | |
return employees; | |
} | |
let currentEmployees = []; // Store current employees for filtering | |
function showEmployeeDetails(event, d) { | |
console.log("Clicked node:", d.data); | |
// Remove selected class from all nodes | |
d3.selectAll('.node').classed('selected', false); | |
// Add selected class to clicked node | |
d3.select(event.currentTarget.parentNode).classed('selected', true); | |
const sidebar = document.getElementById("sidebar"); | |
const sidebarTitle = document.getElementById("sidebarTitle"); | |
const sidebarContent = document.getElementById("sidebarContent"); | |
const searchInput = document.getElementById("employeeSearch"); | |
const totalEmployees = countTotalEmployees(d.data); | |
sidebarTitle.textContent = `${d.data.name} (${totalEmployees} total employees)`; | |
// Get all employees from this node and its children | |
currentEmployees = getAllEmployeesFromNode(d.data); | |
// Clear search input when showing new department | |
searchInput.value = ''; | |
renderEmployeeTable(currentEmployees); | |
sidebar.classList.add("visible"); | |
} | |
function renderEmployeeTable(employees) { | |
const sidebarContent = document.getElementById("sidebarContent"); | |
if (employees.length > 0) { | |
let table = '<table>'; | |
table += ` | |
<tr> | |
<th>Name (ID)</th> | |
<th>Position</th> | |
<th>Office</th> | |
<th>Details</th> | |
</tr>`; | |
// Sort employees by office name for better organization | |
employees.sort((a, b) => a.office.localeCompare(b.office)); | |
employees.forEach(emp => { | |
table += ` | |
<tr class="employee-row"> | |
<td>${emp.name}<br><small class="text-muted">#${emp.id}</small></td> | |
<td> | |
${emp.position.title}<br> | |
<small class="text-muted">Position #${emp.position.number}</small> | |
</td> | |
<td>${emp.office}</td> | |
<td> | |
<strong>Type:</strong> ${emp.type}<br> | |
<strong>Union:</strong> ${emp.union_status}<br> | |
<strong>Level:</strong> ${emp.competitive_level}<br> | |
<strong>Location:</strong> ${emp.location}<br> | |
<strong>Satisfaction:</strong> ${emp.satisfaction}/5<br> | |
<strong>Supervisor:</strong> ${emp.supervisor.name}<br> | |
<small class="text-muted">Supervisor ID: ${emp.supervisor.id}</small> | |
</td> | |
</tr> | |
`; | |
}); | |
table += '</table>'; | |
sidebarContent.innerHTML = table; | |
} else { | |
sidebarContent.innerHTML = '<p>No employees found in this branch.</p>'; | |
} | |
} | |
function filterEmployees() { | |
const searchTerm = document.getElementById("employeeSearch").value.toLowerCase(); | |
const rows = document.querySelectorAll('.employee-row'); | |
let visibleCount = 0; | |
rows.forEach(row => { | |
const text = row.textContent.toLowerCase(); | |
if (text.includes(searchTerm)) { | |
row.classList.remove('hidden'); | |
visibleCount++; | |
// Highlight matching text if there's a search term | |
if (searchTerm) { | |
highlightText(row, searchTerm); | |
} else { | |
// Remove highlights if search is cleared | |
removeHighlights(row); | |
} | |
} else { | |
row.classList.add('hidden'); | |
} | |
}); | |
// Update title with filter count | |
const sidebarTitle = document.getElementById("sidebarTitle"); | |
const totalEmployees = currentEmployees.length; | |
if (searchTerm) { | |
sidebarTitle.textContent = `${visibleCount} of ${totalEmployees} employees shown`; | |
} else { | |
sidebarTitle.textContent = `${totalEmployees} total employees`; | |
} | |
} | |
function highlightText(element, searchTerm) { | |
removeHighlights(element); | |
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); | |
let node; | |
while (node = walker.nextNode()) { | |
const text = node.textContent.toLowerCase(); | |
if (text.includes(searchTerm)) { | |
const newNode = document.createElement('span'); | |
const regex = new RegExp(`(${searchTerm})`, 'gi'); | |
newNode.innerHTML = node.textContent.replace(regex, '<span class="highlight">$1</span>'); | |
node.parentNode.replaceChild(newNode, node); | |
} | |
} | |
} | |
function removeHighlights(element) { | |
const highlights = element.querySelectorAll('.highlight'); | |
highlights.forEach(highlight => { | |
const parent = highlight.parentNode; | |
parent.replaceChild(document.createTextNode(highlight.textContent), highlight); | |
// Clean up any empty spans | |
if (parent.tagName === 'SPAN' && !parent.innerHTML.trim()) { | |
parent.parentNode.removeChild(parent); | |
} | |
}); | |
} | |
// Center the visualization initially | |
function initialCenter() { | |
const scale = 0.5; // Initial zoom level | |
const x = width / 2; | |
const y = height / 4; | |
svgContainer.call( | |
zoom.transform, | |
d3.zoomIdentity | |
.translate(x, y) | |
.scale(scale) | |
); | |
} | |
// Call initial centering | |
initialCenter(); | |
// Add zoom control functions | |
function zoomIn() { | |
svgContainer.transition() | |
.duration(300) | |
.call(zoom.scaleBy, 1.2); | |
} | |
function zoomOut() { | |
svgContainer.transition() | |
.duration(300) | |
.call(zoom.scaleBy, 0.8); | |
} | |
function resetZoom() { | |
svgContainer.transition() | |
.duration(300) | |
.call(zoom.transform, d3.zoomIdentity.translate(width / 2, nodeHeight)); | |
} | |
function closeSidebar() { | |
const sidebar = document.getElementById("sidebar"); | |
sidebar.classList.remove("visible"); | |
// Remove selected state from nodes | |
d3.selectAll('.node').classed('selected', false); | |
} | |
// Add resize functionality | |
const resizer = document.getElementById('sidebar-resizer'); | |
let isResizing = false; | |
let startX; | |
let startWidth; | |
resizer.addEventListener('mousedown', initResize, false); | |
function initResize(e) { | |
isResizing = true; | |
startX = e.clientX; | |
startWidth = parseInt(document.defaultView.getComputedStyle(sidebar).width, 10); | |
document.addEventListener('mousemove', resize, false); | |
document.addEventListener('mouseup', stopResize, false); | |
e.preventDefault(); // Prevent text selection while dragging | |
} | |
function resize(e) { | |
if (!isResizing) return; | |
const width = startWidth - (e.clientX - startX); | |
if (width >= 200 && width <= 800) { // Min and max width limits | |
sidebar.style.width = width + 'px'; | |
} | |
} | |
function stopResize() { | |
isResizing = false; | |
document.removeEventListener('mousemove', resize, false); | |
document.removeEventListener('mouseup', stopResize, false); | |
} | |
// Add click outside to close | |
document.addEventListener('mousedown', function(e) { | |
const clickedNode = e.target.closest('.node'); | |
if (sidebar.classList.contains('visible') && | |
!sidebar.contains(e.target) && | |
!clickedNode) { | |
closeSidebar(); | |
} | |
}); | |
// Update SVG size on window resize | |
window.addEventListener('resize', function() { | |
svgContainer | |
.attr('width', window.innerWidth) | |
.attr('height', window.innerHeight); | |
}); | |
// Initial SVG size | |
svgContainer | |
.attr('width', window.innerWidth) | |
.attr('height', window.innerHeight); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment