HH/templates/organizations/staff_hierarchy_d3.html
2026-02-22 08:35:53 +03:00

697 lines
32 KiB
HTML

{% extends 'layouts/base.html' %}
{% load i18n %}
{% block title %}{% trans "Staff Hierarchy" %} - PX360{% endblock %}
{% block extra_css %}
<style>
/* D3 Styles */
.node circle {
fill: white;
stroke-width: 3px;
transition: all 0.3s ease;
cursor: pointer;
}
.node circle:hover {
filter: drop-shadow(0 0 8px rgba(0, 123, 189, 0.5));
stroke-width: 4px;
}
.node text {
font-size: 13px;
font-weight: 500;
fill: #1e293b;
font-family: 'Inter', sans-serif;
}
.link {
fill: none;
stroke: #cbd5e1;
stroke-width: 2px;
transition: all 0.5s ease;
}
.link:hover {
stroke: #007bbd;
}
/* Loading Animation */
@keyframes pulse-ring {
0% { transform: scale(0.8); opacity: 1; }
100% { transform: scale(1.3); opacity: 0; }
}
.loading-pulse {
animation: pulse-ring 1.5s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
}
/* Progress bar animation */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.progress-shimmer {
background: linear-gradient(90deg, #005696 25%, #007bbd 50%, #005696 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
/* Custom scrollbar for chart */
#hierarchy-chart::-webkit-scrollbar {
width: 8px;
height: 8px;
}
#hierarchy-chart::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
#hierarchy-chart::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
#hierarchy-chart::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6">
<!-- Page Header -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-navy flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-navy to-blue flex items-center justify-center">
<i data-lucide="git-branch" class="w-5 h-5 text-white"></i>
</div>
{% trans "Staff Hierarchy" %}
</h1>
<p class="text-slate mt-2 ml-13">{% trans "Interactive organizational chart with reporting relationships" %}</p>
</div>
<div class="flex items-center gap-3">
<a href="{% url 'organizations:staff_list' %}" class="px-4 py-2.5 text-slate hover:text-navy hover:bg-light rounded-xl font-medium transition flex items-center gap-2 border border-slate-200">
<i data-lucide="list" class="w-5 h-5"></i> {% trans "List View" %}
</a>
<a href="{% url 'organizations:staff_create' %}" class="px-4 py-2.5 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2 shadow-lg shadow-navy/20">
<i data-lucide="plus" class="w-5 h-5"></i> {% trans "Add Staff" %}
</a>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<!-- Total Staff -->
<div class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 flex items-center gap-4">
<div class="w-14 h-14 rounded-xl bg-gradient-to-br from-navy to-blue flex items-center justify-center flex-shrink-0">
<i data-lucide="users" class="w-7 h-7 text-white"></i>
</div>
<div>
<p class="text-slate text-sm font-medium">{% trans "Total Staff" %}</p>
<p class="text-2xl font-bold text-navy" id="totalStaff">-</p>
</div>
</div>
<!-- Top Managers -->
<div class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 flex items-center gap-4">
<div class="w-14 h-14 rounded-xl bg-gradient-to-br from-blue to-cyan-500 flex items-center justify-center flex-shrink-0">
<i data-lucide="crown" class="w-7 h-7 text-white"></i>
</div>
<div>
<p class="text-slate text-sm font-medium">{% trans "Top Managers" %}</p>
<p class="text-2xl font-bold text-navy" id="topManagers">-</p>
</div>
</div>
<!-- Avg Depth -->
<div class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 flex items-center gap-4">
<div class="w-14 h-14 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-500 flex items-center justify-center flex-shrink-0">
<i data-lucide="layers" class="w-7 h-7 text-white"></i>
</div>
<div>
<p class="text-slate text-sm font-medium">{% trans "Avg. Depth" %}</p>
<p class="text-2xl font-bold text-navy" id="avgDepth">-</p>
</div>
</div>
<!-- Load Time -->
<div class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 flex items-center gap-4">
<div class="w-14 h-14 rounded-xl bg-gradient-to-br from-violet-500 to-purple-500 flex items-center justify-center flex-shrink-0">
<i data-lucide="zap" class="w-7 h-7 text-white"></i>
</div>
<div>
<p class="text-slate text-sm font-medium">{% trans "Load Time" %}</p>
<p class="text-2xl font-bold text-navy" id="loadTime">-</p>
<p class="text-xs text-slate">{% trans "milliseconds" %}</p>
</div>
</div>
</div>
<!-- Controls Toolbar -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-4 mb-6">
<div class="flex flex-wrap items-end gap-4">
<!-- Search -->
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium text-slate mb-2">{% trans "Search Staff" %}</label>
<div class="relative">
<i data-lucide="search" class="w-5 h-5 text-slate absolute left-3 top-1/2 -translate-y-1/2"></i>
<input type="text" id="searchInput" placeholder="{% trans 'Search by name or employee ID...' %}"
class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue/20 focus:border-blue transition">
</div>
</div>
<!-- Layout -->
<div class="w-40">
<label class="block text-sm font-medium text-slate mb-2">{% trans "Layout" %}</label>
<div class="relative">
<select id="layoutSelect" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue/20 focus:border-blue transition appearance-none bg-white">
<option value="horizontal">{% trans "Horizontal" %}</option>
<option value="vertical">{% trans "Vertical" %}</option>
<option value="radial">{% trans "Radial" %}</option>
</select>
<i data-lucide="chevron-down" class="w-4 h-4 text-slate absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"></i>
</div>
</div>
<!-- Node Size -->
<div class="w-40">
<label class="block text-sm font-medium text-slate mb-2">{% trans "Node Size" %}</label>
<div class="relative">
<select id="nodeSizeSelect" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue/20 focus:border-blue transition appearance-none bg-white">
<option value="fixed">{% trans "Fixed" %}</option>
<option value="team">{% trans "By Team" %}</option>
<option value="level">{% trans "By Level" %}</option>
</select>
<i data-lucide="chevron-down" class="w-4 h-4 text-slate absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"></i>
</div>
</div>
<!-- Max Depth -->
<div class="w-32">
<label class="block text-sm font-medium text-slate mb-2">{% trans "Max Depth" %}</label>
<div class="relative">
<select id="depthSelect" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue/20 focus:border-blue transition appearance-none bg-white">
<option value="2">2 {% trans "levels" %}</option>
<option value="3" selected>3 {% trans "levels" %}</option>
<option value="4">4 {% trans "levels" %}</option>
<option value="0">{% trans "All" %}</option>
</select>
<i data-lucide="chevron-down" class="w-4 h-4 text-slate absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"></i>
</div>
</div>
<!-- Action Buttons -->
<div class="flex gap-2">
<button id="reloadBtn" class="px-4 py-2.5 bg-light text-navy rounded-xl font-medium hover:bg-slate-100 transition flex items-center gap-2" title="{% trans 'Reload' %}">
<i data-lucide="refresh-cw" class="w-5 h-5"></i>
</button>
<button id="resetZoom" class="px-4 py-2.5 bg-light text-navy rounded-xl font-medium hover:bg-slate-100 transition flex items-center gap-2" title="{% trans 'Reset View' %}">
<i data-lucide="maximize-2" class="w-5 h-5"></i>
</button>
<button id="expandAllBtn" class="px-4 py-2.5 bg-light text-navy rounded-xl font-medium hover:bg-slate-100 transition flex items-center gap-2">
<i data-lucide="expand" class="w-5 h-5"></i> {% trans "Expand" %}
</button>
<button id="collapseAllBtn" class="px-4 py-2.5 bg-light text-navy rounded-xl font-medium hover:bg-slate-100 transition flex items-center gap-2">
<i data-lucide="collapse" class="w-5 h-5"></i> {% trans "Collapse" %}
</button>
</div>
</div>
</div>
<!-- Chart Container -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<!-- Progress Bar -->
<div id="loadingProgress" class="h-1 bg-slate-100 hidden">
<div id="progressBar" class="h-full progress-shimmer w-0 transition-all duration-300"></div>
</div>
<!-- Chart Area -->
<div class="relative" style="height: 600px;">
<!-- Loading State -->
<div id="chartLoader" class="absolute inset-0 flex flex-col items-center justify-center bg-white z-10">
<div class="relative mb-6">
<div class="w-20 h-20 rounded-full bg-blue/10 flex items-center justify-center">
<i data-lucide="git-branch" class="w-10 h-10 text-blue"></i>
</div>
<div class="absolute inset-0 rounded-full border-4 border-blue/20 loading-pulse"></div>
</div>
<h3 class="text-lg font-semibold text-navy mb-2">{% trans "Loading Hierarchy" %}</h3>
<p class="text-slate text-sm" id="loadingStatus">{% trans "Fetching staff data..." %}</p>
</div>
<!-- Empty State -->
<div id="emptyState" class="hidden absolute inset-0 flex flex-col items-center justify-center bg-white">
<div class="w-24 h-24 rounded-full bg-light flex items-center justify-center mb-6">
<i data-lucide="users" class="w-12 h-12 text-slate"></i>
</div>
<h3 class="text-xl font-semibold text-navy mb-2">{% trans "No Staff Data" %}</h3>
<p class="text-slate text-center max-w-md mb-6">{% trans "Start building your organizational chart by adding staff members to the system." %}</p>
<a href="{% url 'organizations:staff_create' %}" class="px-6 py-3 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2">
<i data-lucide="plus" class="w-5 h-5"></i> {% trans "Add First Staff Member" %}
</a>
</div>
<!-- Error State -->
<div id="errorState" class="hidden absolute inset-0 flex flex-col items-center justify-center bg-white">
<div class="w-24 h-24 rounded-full bg-red-50 flex items-center justify-center mb-6">
<i data-lucide="alert-circle" class="w-12 h-12 text-red-500"></i>
</div>
<h3 class="text-xl font-semibold text-red-600 mb-2">{% trans "Failed to Load" %}</h3>
<p class="text-slate text-center max-w-md mb-2" id="errorMessage"></p>
<button id="retryBtn" class="px-6 py-3 bg-navy text-white rounded-xl font-semibold hover:bg-blue transition flex items-center gap-2 mt-4">
<i data-lucide="refresh-cw" class="w-5 h-5"></i> {% trans "Try Again" %}
</button>
</div>
<!-- D3 Chart -->
<div id="hierarchy-chart" class="w-full h-full overflow-auto bg-gradient-to-br from-slate-50 to-white"></div>
</div>
</div>
<!-- Instructions -->
<div class="mt-6 bg-gradient-to-r from-light to-blue-50 rounded-2xl p-6 border border-blue-100">
<h3 class="text-lg font-semibold text-navy mb-4 flex items-center gap-2">
<i data-lucide="info" class="w-5 h-5"></i>
{% trans "How to Use" %}
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-3">
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-lg bg-white flex items-center justify-center flex-shrink-0 shadow-sm">
<i data-lucide="mouse-pointer-click" class="w-4 h-4 text-blue"></i>
</div>
<div>
<p class="font-medium text-navy text-sm">{% trans "Click Nodes" %}</p>
<p class="text-slate text-sm">{% trans "Click to expand or collapse team members" %}</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-lg bg-white flex items-center justify-center flex-shrink-0 shadow-sm">
<i data-lucide="mouse" class="w-4 h-4 text-blue"></i>
</div>
<div>
<p class="font-medium text-navy text-sm">{% trans "Zoom & Pan" %}</p>
<p class="text-slate text-sm">{% trans "Scroll to zoom, drag to pan around" %}</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-lg bg-white flex items-center justify-center flex-shrink-0 shadow-sm">
<i data-lucide="chevron-right" class="w-4 h-4 text-blue"></i>
</div>
<div>
<p class="font-medium text-navy text-sm">{% trans "Lazy Loading" %}</p>
<p class="text-slate text-sm">{% trans "Click ▶ to load more team members on demand" %}</p>
</div>
</div>
</div>
<div class="space-y-3">
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-lg bg-white flex items-center justify-center flex-shrink-0 shadow-sm">
<i data-lucide="search" class="w-4 h-4 text-blue"></i>
</div>
<div>
<p class="font-medium text-navy text-sm">{% trans "Quick Search" %}</p>
<p class="text-slate text-sm">{% trans "Press Enter in search to find and focus on staff" %}</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-lg bg-white flex items-center justify-center flex-shrink-0 shadow-sm">
<i data-lucide="external-link" class="w-4 h-4 text-blue"></i>
</div>
<div>
<p class="font-medium text-navy text-sm">{% trans "View Details" %}</p>
<p class="text-slate text-sm">{% trans "Double-click any node to view staff profile" %}</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-lg bg-white flex items-center justify-center flex-shrink-0 shadow-sm">
<i data-lucide="layers" class="w-4 h-4 text-blue"></i>
</div>
<div>
<p class="font-medium text-navy text-sm">{% trans "Performance" %}</p>
<p class="text-slate text-sm">{% trans "Reduce Max Depth for faster loading with large teams" %}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- D3.js -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof d3 === 'undefined') {
showError('Failed to load D3 library. Please check your connection.');
return;
}
const margin = { top: 40, right: 100, bottom: 40, left: 100 };
let width, height;
let root, treeLayout;
let currentLayout = 'horizontal', nodeSizeBy = 'fixed';
let i = 0, loadedNodes = new Set();
const colors = { primary: '#005696', blue: '#007bbd', success: '#10b981', slate: '#64748b' };
function initChart() {
const container = document.getElementById('hierarchy-chart');
width = container.clientWidth - margin.left - margin.right;
height = container.clientHeight - margin.top - margin.bottom;
d3.select("#hierarchy-chart").selectAll("*").remove();
const svg = d3.select("#hierarchy-chart")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const zoom = d3.zoom().scaleExtent([0.1, 3]).on("zoom", (e) => svg.attr("transform", e.transform));
d3.select("svg").call(zoom);
return svg;
}
let svg = initChart();
function showLoading(show) {
document.getElementById('chartLoader').classList.toggle('hidden', !show);
document.getElementById('loadingProgress').classList.toggle('hidden', !show);
}
function updateProgress(pct) {
document.getElementById('progressBar').style.width = pct + '%';
}
function showEmpty() {
showLoading(false);
document.getElementById('emptyState').classList.remove('hidden');
document.getElementById('hierarchy-chart').classList.add('hidden');
}
function showError(msg) {
showLoading(false);
document.getElementById('errorMessage').textContent = msg;
document.getElementById('errorState').classList.remove('hidden');
document.getElementById('hierarchy-chart').classList.add('hidden');
}
async function fetchHierarchy() {
showLoading(true);
updateProgress(10);
document.getElementById('emptyState').classList.add('hidden');
document.getElementById('errorState').classList.add('hidden');
document.getElementById('hierarchy-chart').classList.remove('hidden');
const maxDepth = document.getElementById('depthSelect').value;
try {
const res = await fetch(`/organizations/api/staff/hierarchy/?max_depth=${maxDepth}`);
const data = await res.json();
updateProgress(50);
if (!data.hierarchy?.length) {
showEmpty();
return;
}
// Update stats
document.getElementById('totalStaff').textContent = data.statistics.total_staff.toLocaleString();
document.getElementById('topManagers').textContent = data.statistics.top_managers.toLocaleString();
document.getElementById('loadTime').textContent = data.statistics.load_time_ms;
// Build tree
const hierarchyData = { name: "Organization", is_virtual_root: true, children: data.hierarchy };
root = d3.hierarchy(hierarchyData);
root.x0 = height / 2;
root.y0 = 0;
// Mark loaded
if (root.children) root.children.forEach(c => loadedNodes.add(c.data.id));
// Collapse deep levels
root.descendants().forEach((d, idx) => {
d.id = idx;
if (d.depth > 2 && d.children) {
d._children = d.children;
d.children = null;
}
});
updateProgress(80);
treeLayout = d3.tree().nodeSize([50, 180]);
update(root);
centerView();
document.getElementById('avgDepth').textContent = calculateAvgDepth(data.hierarchy).toFixed(1);
updateProgress(100);
setTimeout(() => showLoading(false), 200);
} catch (err) {
showError(err.message);
}
}
async function loadChildren(nodeData) {
if (loadedNodes.has(nodeData.id) || !nodeData.has_children) return [];
loadedNodes.add(nodeData.id);
document.getElementById('loadingStatus').textContent = `Loading ${nodeData.name}'s team...`;
try {
const res = await fetch(`/organizations/api/staff/hierarchy/${nodeData.id}/children/`);
const data = await res.json();
document.getElementById('loadingStatus').textContent = '';
return data.children || [];
} catch (err) {
loadedNodes.delete(nodeData.id);
return [];
}
}
function update(source) {
const duration = 400;
const treeData = treeLayout(root);
const nodes = treeData.descendants();
const links = treeData.links();
nodes.forEach(d => d.y = d.depth * 180);
// Nodes
const node = svg.selectAll('g.node').data(nodes, d => d.id || (d.id = ++i));
const nodeEnter = node.enter().append('g')
.attr('class', 'node')
.attr('transform', d => `translate(${source.y0},${source.x0})`)
.on('click', async (e, d) => {
if (d.data.has_children && !d.children && !d._children) {
const children = await loadChildren(d.data);
if (children.length) {
if (!d.children) d.children = [];
children.forEach(c => {
const cn = d3.hierarchy(c);
cn.depth = d.depth + 1;
cn.parent = d;
d.children.push(cn);
});
}
}
click(e, d);
})
.on('dblclick', (e, d) => {
if (d.data?.id && !d.data.is_virtual_root) {
window.location.href = `/organizations/staff/${d.data.id}/`;
}
});
// Circle
nodeEnter.append('circle')
.attr('r', d => d.data.is_virtual_root ? 16 : 8)
.style('fill', d => d.data.is_virtual_root ? colors.slate : (d._children ? colors.primary : colors.success))
.style('stroke', d => d.data.is_virtual_root ? '#94a3b8' : colors.primary);
// Expand indicator
nodeEnter.append('text')
.attr('class', 'expand-indicator')
.attr('x', d => d.children || d._children ? -20 : 20)
.attr('y', 4)
.style('font-size', '12px')
.style('fill', colors.primary)
.text(d => d.data.has_children && !d.children && !d._children ? '▶' : '');
// Name label
nodeEnter.append('text')
.attr('dy', '.35em')
.attr('x', d => d.children || d._children ? -12 : 12)
.attr('text-anchor', d => d.children || d._children ? 'end' : 'start')
.text(d => d.data.name === 'Organization' ? d.data.name : (d.data.name.length > 16 ? d.data.name.substring(0, 16) + '...' : d.data.name))
.style('fill-opacity', 0);
// Tooltip
nodeEnter.append('title')
.text(d => `${d.data.name}${d.data.job_title ? '\n' + d.data.job_title : ''}${d.data.team_size ? '\nTeam: ' + d.data.team_size : ''}`);
// Update
const nodeUpdate = nodeEnter.merge(node);
nodeUpdate.transition().duration(duration)
.attr('transform', d => {
if (currentLayout === 'horizontal') return `translate(${d.y},${d.x})`;
if (currentLayout === 'vertical') return `translate(${d.x},${d.y})`;
const a = (d.x - 90) * Math.PI / 180;
return `translate(${d.y * Math.cos(a)},${d.y * Math.sin(a)})`;
});
nodeUpdate.select('circle')
.attr('r', d => getRadius(d))
.style('fill', d => d.data.is_virtual_root ? colors.slate : (d._children ? colors.primary : colors.success));
nodeUpdate.select('.expand-indicator')
.text(d => d.data.has_children && !d.children && !d._children ? '▶' : '');
nodeUpdate.select('text:not(.expand-indicator)')
.style('fill-opacity', 1)
.attr('x', d => {
const offset = getRadius(d) + 5;
if (currentLayout === 'horizontal') return d.children || d._children ? -offset - 8 : offset + 8;
if (currentLayout === 'vertical') return 0;
return (d.children || d._children ? -1 : 1) * (offset + 8);
})
.attr('text-anchor', d => {
if (currentLayout === 'horizontal') return d.children || d._children ? 'end' : 'start';
if (currentLayout === 'vertical') return 'middle';
return d.children || d._children ? 'end' : 'start';
});
// Remove
node.exit().transition().duration(duration)
.attr('transform', d => `translate(${source.y},${source.x})`)
.remove();
// Links
const link = svg.selectAll('path.link').data(links, d => d.target.id);
link.enter().insert('path', 'g')
.attr('class', 'link')
.attr('d', d => diagonal({x: source.x0, y: source.y0}, {x: source.x0, y: source.y0}))
.merge(link)
.transition().duration(duration)
.attr('d', d => diagonal(d.source, d.target));
link.exit().transition().duration(duration)
.attr('d', d => diagonal({x: source.x, y: source.y}, {x: source.x, y: source.y}))
.remove();
nodes.forEach(d => { d.x0 = d.x; d.y0 = d.y; });
}
function diagonal(s, d) {
if (currentLayout === 'horizontal') {
return `M ${s.y} ${s.x} C ${(s.y + d.y) / 2} ${s.x}, ${(s.y + d.y) / 2} ${d.x}, ${d.y} ${d.x}`;
}
if (currentLayout === 'vertical') {
return `M ${s.x} ${s.y} C ${s.x} ${(s.y + d.y) / 2}, ${d.x} ${(s.y + d.y) / 2}, ${d.x} ${d.y}`;
}
const sA = (s.x - 90) * Math.PI / 180, dA = (d.x - 90) * Math.PI / 180;
return `M ${s.y * Math.cos(sA)} ${s.y * Math.sin(sA)} C ${s.y * Math.cos(sA)} ${(s.y + d.y) / 2 * Math.sin(sA)}, ${d.y * Math.cos(dA)} ${(s.y + d.y) / 2 * Math.sin(dA)}, ${d.y * Math.cos(dA)} ${d.y * Math.sin(dA)}`;
}
function click(e, d) {
if (d.children) { d._children = d.children; d.children = null; }
else { d.children = d._children; d._children = null; }
update(d);
}
function getRadius(d) {
if (d.data.is_virtual_root) return 16;
if (nodeSizeBy === 'fixed') return 8;
if (nodeSizeBy === 'team') return Math.min(7 + Math.sqrt(d.data.team_size || 0), 18);
return Math.max(7, 18 - d.depth * 2);
}
function calculateAvgDepth(h) {
let t = 0, c = 0;
function tr(n, d) {
if (n.name !== 'Organization') { t += d; c++; }
n.children?.forEach(x => tr(x, d + 1));
}
h.forEach(r => tr(r, 1));
return c ? t / c : 0;
}
function centerView() {
d3.select('#hierarchy-chart svg').transition().duration(750)
.call(d3.zoom().transform, d3.zoomIdentity.translate(width / 2, height / 2).scale(0.85));
}
// Events
document.getElementById('searchInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
const term = this.value.toLowerCase();
if (!term || !root) return;
let found = null;
root.descendants().forEach(d => {
if (d.data.name?.toLowerCase().includes(term) || d.data.employee_id?.includes(term)) found = d;
});
if (found) {
let cur = found;
while (cur.parent) {
if (cur.parent._children) { cur.parent.children = cur.parent._children; cur.parent._children = null; }
cur = cur.parent;
}
update(root);
d3.select('#hierarchy-chart svg').transition().duration(750)
.call(d3.zoom().transform, d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-found.y, -found.x));
// Highlight
svg.selectAll('circle').style('stroke', d => d === found ? '#f59e0b' : colors.primary).style('stroke-width', d => d === found ? '4px' : '3px');
} else {
alert('Staff not found');
}
}
});
document.getElementById('layoutSelect').addEventListener('change', function() {
currentLayout = this.value;
treeLayout = d3.tree().nodeSize(currentLayout === 'vertical' ? [160, 45] : [45, 160]);
update(root);
centerView();
});
document.getElementById('nodeSizeSelect').addEventListener('change', function() {
nodeSizeBy = this.value;
update(root);
});
document.getElementById('depthSelect').addEventListener('change', fetchHierarchy);
document.getElementById('reloadBtn').addEventListener('click', fetchHierarchy);
document.getElementById('retryBtn').addEventListener('click', fetchHierarchy);
document.getElementById('expandAllBtn').addEventListener('click', function() {
if (!root) return;
root.descendants().forEach(d => { if (d._children) { d.children = d._children; d._children = null; } });
update(root);
});
document.getElementById('collapseAllBtn').addEventListener('click', function() {
if (!root) return;
root.descendants().forEach(d => { if (d.children && d.depth > 0) { d._children = d.children; d.children = null; } });
update(root);
});
document.getElementById('resetZoom').addEventListener('click', centerView);
window.addEventListener('resize', () => {
svg = initChart();
if (root) update(root);
});
// Init
treeLayout = d3.tree().nodeSize([45, 160]);
fetchHierarchy();
});
</script>
{% endblock %}