Skip to content

Instantly share code, notes, and snippets.

@PatrickFanella
Created February 28, 2025 23:55
Show Gist options
  • Save PatrickFanella/3936f93da4231ea334a728aef4f86dc7 to your computer and use it in GitHub Desktop.
Save PatrickFanella/3936f93da4231ea334a728aef4f86dc7 to your computer and use it in GitHub Desktop.
<!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