Skip to content

Instantly share code, notes, and snippets.

@PatrickFanella
Created February 28, 2025 23:53
Show Gist options
  • Save PatrickFanella/5acac1cdf91c1ad2da2bd10fc8000ac7 to your computer and use it in GitHub Desktop.
Save PatrickFanella/5acac1cdf91c1ad2da2bd10fc8000ac7 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>
<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/d3-flextree.js"></script>
<style>
body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
}
.chart-container {
width: 100%;
height: 100vh;
}
/* Add these new styles */
#sidebar {
width: 750px;
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;
z-index: 1000;
}
#sidebar.visible {
transform: translateX(0);
}
#close-sidebar {
position: absolute;
top: 20px;
right: 20px;
cursor: pointer;
font-size: 24px;
color: #666;
background: none;
border: none;
padding: 0;
z-index: 2;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s;
}
#close-sidebar:hover {
color: #333;
background-color: rgba(0, 0, 0, 0.05);
}
#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;
}
/* Table styles */
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
table-layout: fixed;
}
th, td {
padding: 12px 8px;
text-align: left;
border-bottom: 1px solid #ddd;
}
td {
vertical-align: top;
}
th {
background-color: #fff;
position: sticky;
top: 0;
z-index: 1;
}
th:nth-child(1) { width: 20%; }
th:nth-child(2) { width: 25%; }
th:nth-child(3) { width: 15%; }
th:nth-child(4) { width: 40%; }
.text-muted {
color: #666;
font-size: 0.85em;
}
.node {
cursor: pointer;
}
.node.selected rect {
stroke: #2196F3;
stroke-width: 2px;
}
/* Add these resize-related styles */
#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);
}
/* Add these search-related styles */
#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;
}
/* Add these new styles */
#colorSchemeSelector {
position: fixed;
top: 20px;
left: 20px;
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 1000;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.radio-option {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.radio-option input[type="radio"] {
cursor: pointer;
}
</style>
</head>
<body>
<div id="colorSchemeSelector">
<h4 style="margin: 0 0 10px 0">Color Scheme</h4>
<div class="radio-group">
<label class="radio-option">
<input type="radio" name="colorScheme" value="none" checked onchange="updateColorScheme(this.value)">
None
</label>
<label class="radio-option">
<input type="radio" name="colorScheme" value="workforce" onchange="updateColorScheme(this.value)">
Percentage of Workforce
</label>
<label class="radio-option">
<input type="radio" name="colorScheme" value="union" onchange="updateColorScheme(this.value)">
Union Percentage
</label>
<label class="radio-option">
<input type="radio" name="colorScheme" value="fulltime" onchange="updateColorScheme(this.value)">
Full-Time Percentage
</label>
<label class="radio-option">
<input type="radio" name="colorScheme" value="statutory" onchange="updateColorScheme(this.value)">
Statutorily Required
</label>
</div>
</div>
<div class="chart-container"></div>
<!-- Add sidebar HTML -->
<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 this list at the top of your script
const highlightedOffices = [
"Operations",
"Field Operations",
"OFFICE OF GOVERNMENT CONTRACTING & BUSINESS DEVELOPMENT",
"PERSONNEL SECURITY DIVISION",
// "Office of Financial Assistance",
// "Office of Performance and Systems Management",
// "Office of Credit Risk Management",
// "Office of Surety Guarantees",
// "7(a)",
// "504 Program",
// "Secondary Market (SMG)",
// "Microloan Enterprise Development"
// Add more office names here
];
function transformOrgData(node, parentId = "", prefix = "") {
let result = [];
// Create current node
const currentId = prefix ? `${prefix}-${node.name}` : node.name;
// Calculate statistics for this node
const allEmployees = getAllEmployeesFromNode(node);
const totalEmployees = allEmployees.length;
// Calculate union percentage
const unionEmployees = allEmployees.filter(emp => [8888, 7777].includes(emp.union_status));
const unionPercentage = totalEmployees > 0 ? unionEmployees.length / totalEmployees : 0;
// Calculate full-time percentage
const fulltimeEmployees = allEmployees.filter(emp =>
emp.type === "Full-Time" || emp.type === "Full Time"
);
const fulltimePercentage = totalEmployees > 0 ? fulltimeEmployees.length / totalEmployees : 0;
const currentNode = {
id: currentId,
parentId: parentId,
name: node.name,
position: `${node.name}`,
employeeCount: node.employees ? node.employees.length : 0,
totalEmployees: totalEmployees,
unionPercentage: unionPercentage,
fulltimePercentage: fulltimePercentage
};
// Add current node to results
result.push(currentNode);
// Process children
if (node.children && node.children.length > 0) {
node.children.forEach((child, index) => {
const childResults = transformOrgData(child, currentId, `${prefix}${index}`);
result = result.concat(childResults);
});
}
return result;
}
document.addEventListener('DOMContentLoaded', () => {
// Make transformedData available globally
window.transformedData = transformOrgData(data);
const totalCompanyEmployees = calculateTotalEmployees(data);
// Debug: Log the transformed data to see if percentages are calculated correctly
console.log("Transformed data with stats:", window.transformedData);
// Make chartInstance available globally
window.chartInstance = new d3.OrgChart()
.container('.chart-container')
.nodeHeight(d => {
// Create a temporary div to measure the actual content height
const temp = document.createElement('div');
temp.style.position = 'absolute';
temp.style.visibility = 'hidden';
temp.style.width = '220px'; // Fixed width
temp.style.padding = '12px';
temp.style.boxSizing = 'border-box'; // Include padding in width calculation
temp.style.border = '1px solid #E4E2E9';
temp.style.borderRadius = '5px';
temp.innerHTML = `
<div style="font-weight:bold;margin-bottom:5px;word-wrap:break-word;">${d.data.name}</div>
<div style="color:#716E7B;font-size:12px">${d.data.position}</div>
<div style="color:#716E7B;font-size:12px">Team Size: ${d.data.totalEmployees}</div>
`;
document.body.appendChild(temp);
const height = temp.offsetHeight;
document.body.removeChild(temp);
return height; // Remove extra padding - let the actual height be the container height
})
.nodeWidth(d => 220)
.childrenMargin(d => 50)
.compactMarginBetween(d => 35)
.compact(false)
.neighbourMargin((a, b) => 50)
.siblingsMargin(d => 60)
.nodeContent((d, i, arr, state) => {
let backgroundColor = 'white';
let borderColor = '#E4E2E9';
let textColor = '#333';
let percentageText = '';
if (currentColorScheme === 'workforce') {
const percentage = d.data.totalEmployees / totalCompanyEmployees;
backgroundColor = getColorForPercentage(percentage);
textColor = percentage > 0.5 ? 'white' : '#333';
percentageText = `<div style="font-size:12px">${(percentage * 100).toFixed(2)}% of workforce</div>`;
}
else if (currentColorScheme === 'union') {
const percentage = d.data.unionPercentage || 0;
backgroundColor = getColorForPercentage(percentage, [230, 240, 255], [13, 71, 161]);
textColor = percentage > 0.5 ? 'white' : '#333';
percentageText = `<div style="font-size:12px">${(percentage * 100).toFixed(2)}% union</div>`;
}
else if (currentColorScheme === 'fulltime') {
const percentage = d.data.fulltimePercentage || 0;
backgroundColor = getColorForPercentage(percentage, [255, 240, 230], [161, 71, 13]);
textColor = percentage > 0.5 ? 'white' : '#333';
percentageText = `<div style="font-size:12px">${(percentage * 100).toFixed(2)}% full-time</div>`;
}
else if (currentColorScheme === 'statutory') {
// Check if this office is in the highlighted list
if (highlightedOffices.includes(d.data.name)) {
backgroundColor = '#4CAF50'; // Green color
textColor = 'white';
percentageText = `<div style="font-size:12px">Statutorily Required</div>`;
}
}
if (currentColorScheme === 'none') {
// Original styling for 'none' scheme
return `
<div style="
height: ${d.height}px;
width: ${d.width}px;
padding: 12px;
background-color: white;
border: 1px solid #E4E2E9;
border-radius: 5px;
box-sizing: border-box;
display: flex;
flex-direction: column;
cursor: pointer;
" onclick="showSidebar('${d.data.name}', ${d.data.totalEmployees})">
<div style="font-weight:bold;margin-bottom:5px;word-wrap:break-word;">${d.data.name}</div>
<div style="color:#716E7B;font-size:12px">${d.data.position}</div>
<div style="color:#716E7B;font-size:12px">Team Size: ${d.data.totalEmployees}</div>
</div>
`;
} else {
// Styling for color schemes
return `
<div style="
height: ${d.height}px;
width: ${d.width}px;
padding: 12px;
background-color: ${backgroundColor};
border: 1px solid ${borderColor};
border-radius: 5px;
box-sizing: border-box;
display: flex;
flex-direction: column;
cursor: pointer;
color: ${textColor};
" onclick="showSidebar('${d.data.name}', ${d.data.totalEmployees})">
<div style="font-weight:bold;margin-bottom:5px;word-wrap:break-word;">${d.data.name}</div>
<div style="font-size:12px">${d.data.position}</div>
<div style="font-size:12px">Team Size: ${d.data.totalEmployees}</div>
${percentageText}
</div>
`;
}
});
window.chartInstance
.data(window.transformedData)
.render();
// Start with everything collapsed except root
setTimeout(() => {
const root = window.chartInstance.getChartState().data[0];
window.chartInstance.collapseAll();
window.chartInstance.expandNode(root.id).render();
}, 100);
// Add click outside event listener after DOM is loaded
document.addEventListener('click', function(e) {
const sidebar = document.getElementById("sidebar");
// Only process if sidebar is visible
if (sidebar.classList.contains('visible')) {
// Check if click is outside sidebar, not on a node, and not on color scheme selector
const clickedOnNode = e.target.closest('.node') !== null;
const clickedOnSidebar = sidebar.contains(e.target);
const clickedOnColorScheme = document.getElementById("colorSchemeSelector").contains(e.target);
if (!clickedOnSidebar && !clickedOnNode && !clickedOnColorScheme) {
closeSidebar();
}
}
});
});
// Add these new functions
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
// Modify showSidebar function to include search functionality
function showSidebar(name, totalEmployees) {
// Remove selected class from all nodes
d3.selectAll('.node').classed('selected', false);
// Add selected class to clicked node
const clickedNode = d3.select(event.target.closest('.node'));
clickedNode.classed('selected', true);
const sidebar = document.getElementById("sidebar");
const sidebarTitle = document.getElementById("sidebarTitle");
const sidebarContent = document.getElementById("sidebarContent");
// Find the node data
const nodeData = findNodeByName(data, name);
const employees = getAllEmployeesFromNode(nodeData);
// Update currentEmployees array
currentEmployees = employees;
sidebarTitle.textContent = `${name} (${employees.length} total employees)`;
// Clear search input when showing new department
const searchInput = document.getElementById("employeeSearch");
searchInput.value = '';
sidebarContent.innerHTML = renderEmployeeTable(employees);
sidebar.classList.add("visible");
}
// Helper function to find node by name
function findNodeByName(node, name) {
if (node.name === name) {
return node;
}
if (node.children) {
for (const child of node.children) {
const found = findNodeByName(child, name);
if (found) return found;
}
}
return null;
}
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 sidebar = document.getElementById('sidebar');
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 these new functions
function filterEmployees() {
const searchTerm = document.getElementById("employeeSearch").value.toLowerCase();
const rows = document.querySelectorAll('#sidebarContent table tr.employee-row');
let visibleCount = 0;
// Add employee-row class to all rows except header
document.querySelectorAll('#sidebarContent table tr:not(:first-child)').forEach(row => {
row.classList.add('employee-row');
});
document.querySelectorAll('.employee-row').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);
}
});
}
function renderEmployeeTable(employees) {
// Calculate statistics
const totalEmployees = employees.length;
const unionEmployees = employees.filter(emp => [8888, 7777].includes(emp.union_status));
const unionPercentage = ((unionEmployees.length / totalEmployees) * 100).toFixed(1);
const fulltimeEmployees = employees.filter(emp => (emp.type === "Full-Time" || emp.type === "Full Time"));
const fulltimePercentage = ((fulltimeEmployees.length / totalEmployees) * 100).toFixed(1);
let tableHtml = `
<div style="margin-bottom: 20px; padding: 15px; background-color: #f8f9fa; border-radius: 5px;">
<div style="margin-bottom: 10px;">
<strong>Union Status:</strong> ${unionPercentage}% Union (${unionEmployees.length} of ${totalEmployees})
</div>
<div>
<strong>Employment Type:</strong> ${fulltimePercentage}% Full Time (${fulltimeEmployees.length} of ${totalEmployees})
</div>
</div>
`;
if (employees.length > 0) {
tableHtml += '<table>';
tableHtml += `
<tr>
<th>Name (ID)</th>
<th>Position</th>
<th>Office</th>
<th>Details</th>
</tr>`;
employees.sort((a, b) => a.office.localeCompare(b.office));
employees.forEach(emp => {
const union_status = [8888, 7777].includes(emp.union_status) ? 'Union' : 'Non-Union';
const rowStyle = union_status === 'Union' ? 'background-color: #f0f7ff;' : '';
tableHtml += `
<tr class="employee-row" style="${rowStyle}">
<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> ${union_status}<br>
<strong>Location:</strong> ${emp.location}<br>
<strong>Appt Type:</strong> ${emp.satisfaction}/5<br>
<strong>Supervisor:</strong> ${emp.supervisor.name}<br>
<small class="text-muted">Supervisor ID: ${emp.supervisor.id}</small>
</td>
</tr>
`;
});
tableHtml += '</table>';
} else {
tableHtml += '<p>No employees found in this department.</p>';
}
return tableHtml;
}
// Add these new functions
function calculateTotalEmployees(node) {
let total = node.employees ? node.employees.length : 0;
if (node.children) {
node.children.forEach(child => {
total += calculateTotalEmployees(child);
});
}
return total;
}
function getColorForPercentage(percentage, minColor = [230, 240, 255], maxColor = [13, 71, 161]) {
const r = Math.floor(minColor[0] + (maxColor[0] - minColor[0]) * percentage);
const g = Math.floor(minColor[1] + (maxColor[1] - minColor[1]) * percentage);
const b = Math.floor(minColor[2] + (maxColor[2] - minColor[2]) * percentage);
return `rgb(${r}, ${g}, ${b})`;
}
let currentColorScheme = 'none';
function updateColorScheme(scheme) {
currentColorScheme = scheme;
// Access the global chartInstance
if (window.chartInstance) {
// Store the current expanded/collapsed state
const currentState = window.chartInstance.getChartState();
const nodeStates = {};
// Record the collapsed state of each node
currentState.data.forEach(node => {
nodeStates[node.id] = {
_collapsed: node._collapsed
};
});
// Re-render the chart with the new color scheme
window.chartInstance
.data(window.transformedData)
.render();
// Wait for render to complete before restoring state
setTimeout(() => {
// Get the updated nodes
const updatedState = window.chartInstance.getChartState();
// Restore the collapsed/expanded state for each node
updatedState.data.forEach(node => {
if (nodeStates[node.id]) {
// If the node was collapsed before, collapse it again
if (nodeStates[node.id]._collapsed && !node._collapsed) {
window.chartInstance.collapseNode(node.id);
}
// If the node was expanded before, expand it again
else if (!nodeStates[node.id]._collapsed && node._collapsed) {
window.chartInstance.expandNode(node.id);
}
}
});
// Final render to show the changes
window.chartInstance.render();
}, 50);
}
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment