618 lines
24 KiB
HTML
618 lines
24 KiB
HTML
{% extends 'layouts/base.html' %}
|
|
{% load i18n %}
|
|
|
|
{% block title %}{% trans "Staff Hierarchy - D3 Visualization" %}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid">
|
|
<!-- Page Title -->
|
|
<div class="page-title-box">
|
|
<h4>{% trans "Staff Hierarchy" %}</h4>
|
|
<ol class="breadcrumb">
|
|
<li class="breadcrumb-item"><a href="{% url 'organizations:hospital_list' %}">{% trans "Organizations" %}</a></li>
|
|
<li class="breadcrumb-item"><a href="{% url 'organizations:staff_list' %}">{% trans "Staff" %}</a></li>
|
|
<li class="breadcrumb-item active">{% trans "Hierarchy" %}</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<!-- Statistics Cards -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-4">
|
|
<div class="stat-card">
|
|
<div class="card-body">
|
|
<div class="stat-icon bg-teal">
|
|
<i class="bi bi-people"></i>
|
|
</div>
|
|
<div class="stat-value" id="totalStaff">-</div>
|
|
<div class="stat-label">{% trans "Total Staff" %}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="stat-card">
|
|
<div class="card-body">
|
|
<div class="stat-icon bg-blue">
|
|
<i class="bi bi-diagram-3"></i>
|
|
</div>
|
|
<div class="stat-value" id="topManagers">-</div>
|
|
<div class="stat-label">{% trans "Top Managers" %}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="stat-card">
|
|
<div class="card-body">
|
|
<div class="stat-icon bg-green">
|
|
<i class="bi bi-layers"></i>
|
|
</div>
|
|
<div class="stat-value" id="avgDepth">-</div>
|
|
<div class="stat-label">{% trans "Avg. Hierarchy Depth" %}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Controls -->
|
|
<div class="card mb-4">
|
|
<div class="card-body">
|
|
<div class="row g-3">
|
|
<div class="col-md-4">
|
|
<label class="form-label">{% trans "Search Staff" %}</label>
|
|
<div class="input-group">
|
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
|
<input type="text" class="form-control" id="searchInput" placeholder="{% trans 'Search by name or ID' %}">
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">{% trans "Layout" %}</label>
|
|
<select class="form-select" id="layoutSelect">
|
|
<option value="horizontal">{% trans "Horizontal" %}</option>
|
|
<option value="vertical">{% trans "Vertical" %}</option>
|
|
<option value="radial">{% trans "Radial" %}</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">{% trans "Node Size By" %}</label>
|
|
<select class="form-select" id="nodeSizeSelect">
|
|
<option value="fixed">{% trans "Fixed Size" %}</option>
|
|
<option value="team">{% trans "Team Size" %}</option>
|
|
<option value="level">{% trans "Hierarchy Level" %}</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2 d-flex align-items-end">
|
|
<button class="btn btn-primary w-100" id="resetZoom">
|
|
<i class="bi bi-zoom-in"></i> {% trans "Reset View" %}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- D3 Visualization Container -->
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0">
|
|
<i class="bi bi-diagram-3"></i> {% trans "Organizational Chart" %}
|
|
</h5>
|
|
<div class="btn-group">
|
|
<button class="btn btn-sm btn-outline-primary" id="expandAllBtn">
|
|
<i class="bi bi-plus-square"></i> {% trans "Expand All" %}
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-primary" id="collapseAllBtn">
|
|
<i class="bi bi-dash-square"></i> {% trans "Collapse All" %}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="hierarchy-chart" style="width: 100%; height: 800px; overflow: hidden;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Instructions -->
|
|
<div class="card mt-4">
|
|
<div class="card-header">
|
|
<i class="bi bi-info-circle"></i> {% trans "Instructions" %}
|
|
</div>
|
|
<div class="card-body">
|
|
<ul class="mb-0">
|
|
<li><strong>{% trans "Zoom & Pan" %}:</strong> {% trans "Use mouse wheel to zoom, click and drag to pan the chart" %}</li>
|
|
<li><strong>{% trans "Expand/Collapse" %}:</strong> {% trans "Click on a node to expand or collapse its children" %}</li>
|
|
<li><strong>{% trans "View Details" %}:</strong> {% trans "Double-click on a node to view full staff details" %}</li>
|
|
<li><strong>{% trans "Search" %}:</strong> {% trans "Type in the search box to find a staff member. The chart will automatically navigate to them" %}</li>
|
|
<li><strong>{% trans "Layout Options" %}:</strong> {% trans "Switch between horizontal, vertical, and radial layouts" %}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- D3 Hierarchy Visualization Script -->
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Chart dimensions
|
|
const margin = { top: 40, right: 120, bottom: 40, left: 120 };
|
|
let width = document.getElementById('hierarchy-chart').offsetWidth - margin.left - margin.right;
|
|
let height = 800 - margin.top - margin.bottom;
|
|
|
|
// Create SVG
|
|
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})`);
|
|
|
|
// Zoom behavior
|
|
const zoom = d3.zoom()
|
|
.scaleExtent([0.1, 3])
|
|
.on("zoom", (event) => {
|
|
svg.attr("transform", event.transform);
|
|
});
|
|
|
|
d3.select("svg").call(zoom);
|
|
|
|
// Global variables
|
|
let root = null;
|
|
let treeLayout = null;
|
|
let currentLayout = 'horizontal';
|
|
let nodeSizeBy = 'fixed';
|
|
let i = 0;
|
|
|
|
// Fetch hierarchy data
|
|
fetchHierarchyData();
|
|
|
|
function fetchHierarchyData() {
|
|
fetch('/organizations/api/staff/hierarchy/')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
console.log('Hierarchy data received:', data);
|
|
|
|
// Check if hierarchy is empty
|
|
if (!data.hierarchy || data.hierarchy.length === 0) {
|
|
document.getElementById('totalStaff').textContent = '0';
|
|
document.getElementById('topManagers').textContent = '0';
|
|
document.getElementById('avgDepth').textContent = '0';
|
|
|
|
// Show empty state message
|
|
const chartContainer = document.getElementById('hierarchy-chart');
|
|
chartContainer.innerHTML = `
|
|
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; text-align: center; padding: 40px;">
|
|
<i class="bi bi-people" style="font-size: 64px; color: #ccc; margin-bottom: 20px;"></i>
|
|
<h4 style="color: #666; margin-bottom: 10px;">No Staff Data Available</h4>
|
|
<p style="color: #888; max-width: 400px; margin-bottom: 20px;">
|
|
The staff hierarchy is empty. Please import staff data using the CSV import command.
|
|
</p>
|
|
<div style="background: #f5f5f5; padding: 15px; border-radius: 8px; text-align: left; max-width: 500px;">
|
|
<strong>Import Staff Data:</strong>
|
|
<code style="display: block; margin-top: 8px; padding: 8px; background: #fff; border-radius: 4px; overflow-x: auto;">
|
|
python manage.py import_staff_csv your_staff_data.csv
|
|
</code>
|
|
</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Update statistics
|
|
document.getElementById('totalStaff').textContent = data.statistics.total_staff.toLocaleString();
|
|
document.getElementById('topManagers').textContent = data.statistics.top_managers.toLocaleString();
|
|
document.getElementById('avgDepth').textContent = calculateAverageDepth(data.hierarchy).toFixed(1);
|
|
|
|
// Create hierarchy
|
|
const hierarchyData = {
|
|
name: "Organization",
|
|
children: data.hierarchy
|
|
};
|
|
|
|
root = d3.hierarchy(hierarchyData);
|
|
root.x0 = height / 2;
|
|
root.y0 = 0;
|
|
|
|
// Only collapse nodes that actually have children and are at depth > 1
|
|
root.descendants().forEach((d, i) => {
|
|
d.id = i;
|
|
// Only collapse if node has children and is deeper than first level
|
|
if (d.depth > 1 && d.children && d.children.length > 0) {
|
|
d._children = d.children;
|
|
d.children = null;
|
|
}
|
|
});
|
|
|
|
console.log('Root hierarchy:', root);
|
|
console.log('Total nodes:', root.descendants().length);
|
|
|
|
// Initial render
|
|
update(root);
|
|
|
|
// Center the view
|
|
centerView();
|
|
})
|
|
.catch(error => {
|
|
console.error('Error fetching hierarchy data:', error);
|
|
|
|
// Show error message
|
|
const chartContainer = document.getElementById('hierarchy-chart');
|
|
chartContainer.innerHTML = `
|
|
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; text-align: center; padding: 40px;">
|
|
<i class="bi bi-exclamation-triangle" style="font-size: 64px; color: #dc3545; margin-bottom: 20px;"></i>
|
|
<h4 style="color: #dc3545; margin-bottom: 10px;">Error Loading Hierarchy</h4>
|
|
<p style="color: #666; max-width: 400px;">
|
|
Failed to load hierarchy data. Please check your connection and try again.
|
|
</p>
|
|
<p style="color: #888; font-size: 14px;">
|
|
Error: ${error.message}
|
|
</p>
|
|
</div>
|
|
`;
|
|
});
|
|
}
|
|
|
|
function update(source) {
|
|
// Get tree layout based on current orientation
|
|
const duration = 500;
|
|
|
|
// Assigns the x, y position for the nodes
|
|
const treeData = treeLayout(root);
|
|
|
|
// Compute the new tree layout
|
|
const nodes = treeData.descendants();
|
|
const links = treeData.links();
|
|
|
|
// Normalize for fixed-depth
|
|
nodes.forEach(d => {
|
|
d.y = d.depth * 200;
|
|
});
|
|
|
|
// ****************** Nodes section ******************
|
|
|
|
// Update the nodes
|
|
const node = svg.selectAll('g.node')
|
|
.data(nodes, d => d.id || (d.id = ++i));
|
|
|
|
// Enter any new modes at the parent's previous position
|
|
const nodeEnter = node.enter().append('g')
|
|
.attr('class', 'node')
|
|
.attr("transform", d => `translate(${source.y0},${source.x0})`)
|
|
.on('click', click)
|
|
.on('dblclick', d => {
|
|
// Only navigate if node has a real ID (not virtual root)
|
|
if (d.data && d.data.id && !d.data.is_virtual_root) {
|
|
window.location.href = `/organizations/staff/${d.data.id}/`;
|
|
}
|
|
});
|
|
|
|
// Add circle for the nodes
|
|
nodeEnter.append('circle')
|
|
.attr('class', 'node')
|
|
.attr('r', d => d.data.is_virtual_root ? 20 : 10) // Larger for virtual root
|
|
.style("fill", d => d.data.is_virtual_root ? "#666" : (d._children ? "var(--hh-primary)" : "var(--hh-success)"))
|
|
.style("stroke", d => d.data.is_virtual_root ? "#999" : "var(--hh-primary)")
|
|
.style("stroke-width", "2px")
|
|
.style("cursor", d => d.data.is_virtual_root ? "default" : "pointer");
|
|
|
|
// Add labels for the nodes
|
|
nodeEnter.append('text')
|
|
.attr("dy", ".35em")
|
|
.attr("x", d => d.children || d._children ? -15 : 15)
|
|
.attr("text-anchor", d => d.children || d._children ? "end" : "start")
|
|
.text(d => {
|
|
if (d.data.name === "Organization") return d.data.name;
|
|
return d.data.name.length > 20 ? d.data.name.substring(0, 20) + '...' : d.data.name;
|
|
})
|
|
.style("fill-opacity", 0);
|
|
|
|
// Add tooltip
|
|
nodeEnter.append('title')
|
|
.text(d => {
|
|
if (!d.data || !d.data.name) return "Staff";
|
|
if (d.data.name === "Organization") return d.data.name;
|
|
return `${d.data.name}\n${d.data.job_title || ''}\nTeam: ${d.data.team_size || 0}`;
|
|
});
|
|
|
|
// UPDATE
|
|
const nodeUpdate = nodeEnter.merge(node);
|
|
|
|
// Transition to the proper position for the node
|
|
nodeUpdate.transition()
|
|
.duration(duration)
|
|
.attr("transform", d => {
|
|
if (currentLayout === 'horizontal') {
|
|
return `translate(${d.y},${d.x})`;
|
|
} else if (currentLayout === 'vertical') {
|
|
return `translate(${d.x},${d.y})`;
|
|
} else {
|
|
// Radial
|
|
const angle = (d.x - 90) * Math.PI / 180;
|
|
const radius = d.y;
|
|
return `translate(${radius * Math.cos(angle)},${radius * Math.sin(angle)})`;
|
|
}
|
|
});
|
|
|
|
// Update the node attributes and style
|
|
nodeUpdate.select('circle.node')
|
|
.attr('r', d => getNodeRadius(d))
|
|
.style("fill", d => {
|
|
if (d.data.is_virtual_root) return "#666";
|
|
return d._children ? "var(--hh-primary)" : "var(--hh-success)";
|
|
})
|
|
.style("stroke", d => d.data.is_virtual_root ? "#999" : "var(--hh-primary)")
|
|
.attr('cursor', d => d.data.is_virtual_root ? "default" : "pointer");
|
|
|
|
// Update the text
|
|
nodeUpdate.select('text')
|
|
.style("fill-opacity", 1)
|
|
.attr("x", d => {
|
|
if (currentLayout === 'horizontal') {
|
|
return d.children || d._children ? -15 : 15;
|
|
} else if (currentLayout === 'vertical') {
|
|
return 0;
|
|
} else {
|
|
// Radial
|
|
const angle = (d.x - 90) * Math.PI / 180;
|
|
return (d.children || d._children ? -1 : 1) * 15;
|
|
}
|
|
})
|
|
.attr("dy", ".35em")
|
|
.attr("text-anchor", d => {
|
|
if (currentLayout === 'horizontal') {
|
|
return d.children || d._children ? "end" : "start";
|
|
} else if (currentLayout === 'vertical') {
|
|
return "middle";
|
|
} else {
|
|
// Radial
|
|
return d.children || d._children ? "end" : "start";
|
|
}
|
|
});
|
|
|
|
// Remove any exiting nodes
|
|
const nodeExit = node.exit().transition()
|
|
.duration(duration)
|
|
.attr("transform", d => `translate(${source.y},${source.x})`)
|
|
.remove();
|
|
|
|
nodeExit.select('circle')
|
|
.attr('r', 1e-6);
|
|
|
|
nodeExit.select('text')
|
|
.style("fill-opacity", 1e-6);
|
|
|
|
// ****************** Links section ******************
|
|
|
|
// Update the links
|
|
const link = svg.selectAll('path.link')
|
|
.data(links, d => d.target.id);
|
|
|
|
// Enter any new links at the parent's previous position
|
|
const linkEnter = link.enter().insert('path', "g")
|
|
.attr("class", "link")
|
|
.attr('d', d => {
|
|
const o = {x: source.x0, y: source.y0};
|
|
return diagonal(o, o);
|
|
})
|
|
.style("fill", "none")
|
|
.style("stroke", "#ccc")
|
|
.style("stroke-width", "2px");
|
|
|
|
// UPDATE
|
|
const linkUpdate = linkEnter.merge(link);
|
|
|
|
// Transition back to the parent element position
|
|
linkUpdate.transition()
|
|
.duration(duration)
|
|
.attr('d', d => diagonal(d.source, d.target));
|
|
|
|
// Remove any exiting links
|
|
link.exit().transition()
|
|
.duration(duration)
|
|
.attr('d', d => {
|
|
const o = {x: source.x, y: source.y};
|
|
return diagonal(o, o);
|
|
})
|
|
.remove();
|
|
|
|
// Store the old positions for transition
|
|
nodes.forEach(d => {
|
|
d.x0 = d.x;
|
|
d.y0 = d.y;
|
|
});
|
|
}
|
|
|
|
// Creates a curved (diagonal) path from parent to the child nodes
|
|
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}`;
|
|
} else 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}`;
|
|
} else {
|
|
// Radial
|
|
const sAngle = (s.x - 90) * Math.PI / 180;
|
|
const dAngle = (d.x - 90) * Math.PI / 180;
|
|
const sRadius = s.y;
|
|
const dRadius = d.y;
|
|
|
|
return `M ${sRadius * Math.cos(sAngle)} ${sRadius * Math.sin(sAngle)}
|
|
C ${sRadius * Math.cos(sAngle)} ${(sRadius + dRadius) / 2 * Math.sin(sAngle)},
|
|
${dRadius * Math.cos(dAngle)} ${(sRadius + dRadius) / 2 * Math.sin(dAngle)},
|
|
${dRadius * Math.cos(dAngle)} ${dRadius * Math.sin(dAngle)}`;
|
|
}
|
|
}
|
|
|
|
// Toggle children on click
|
|
function click(event, d) {
|
|
if (d.children) {
|
|
d._children = d.children;
|
|
d.children = null;
|
|
} else {
|
|
d.children = d._children;
|
|
d._children = null;
|
|
}
|
|
update(d);
|
|
}
|
|
|
|
// Get node radius based on nodeSizeBy setting
|
|
function getNodeRadius(d) {
|
|
// Virtual root always gets larger size
|
|
if (d.data.is_virtual_root) {
|
|
return 20;
|
|
}
|
|
|
|
if (nodeSizeBy === 'fixed') {
|
|
return 10;
|
|
} else if (nodeSizeBy === 'team') {
|
|
const teamSize = d.data.team_size || 0;
|
|
return Math.min(8 + Math.sqrt(teamSize) * 2, 25);
|
|
} else if (nodeSizeBy === 'level') {
|
|
return Math.max(10, 30 - d.depth * 3);
|
|
}
|
|
return 10;
|
|
}
|
|
|
|
// Calculate average depth
|
|
function calculateAverageDepth(hierarchy) {
|
|
let totalDepth = 0;
|
|
let count = 0;
|
|
|
|
function traverse(node, depth) {
|
|
if (node.name !== "Organization") {
|
|
totalDepth += depth;
|
|
count++;
|
|
}
|
|
if (node.children) {
|
|
node.children.forEach(child => traverse(child, depth + 1));
|
|
}
|
|
}
|
|
|
|
hierarchy.forEach(root => traverse(root, 1));
|
|
return count > 0 ? totalDepth / count : 0;
|
|
}
|
|
|
|
// Center the view
|
|
function centerView() {
|
|
const chart = d3.select("#hierarchy-chart svg");
|
|
const zoomIdentity = d3.zoomIdentity.translate(width/2, height/2).scale(1);
|
|
chart.transition().duration(750).call(zoom.transform, zoomIdentity);
|
|
}
|
|
|
|
// Search functionality
|
|
document.getElementById('searchInput').addEventListener('keyup', function(e) {
|
|
if (e.key === 'Enter') {
|
|
const searchTerm = e.target.value.toLowerCase();
|
|
if (!searchTerm || !root) return;
|
|
|
|
// Find matching node
|
|
let foundNode = null;
|
|
root.descendants().forEach(d => {
|
|
if (d.data.name && d.data.name.toLowerCase().includes(searchTerm) ||
|
|
d.data.employee_id && d.data.employee_id.toString().includes(searchTerm)) {
|
|
foundNode = d;
|
|
}
|
|
});
|
|
|
|
if (foundNode) {
|
|
// Expand all nodes leading to the found node
|
|
let current = foundNode;
|
|
while (current.parent) {
|
|
if (current.parent._children) {
|
|
current.parent.children = current.parent._children;
|
|
current.parent._children = null;
|
|
}
|
|
current = current.parent;
|
|
}
|
|
|
|
update(root);
|
|
|
|
// Zoom to the found node
|
|
const transform = d3.zoomIdentity
|
|
.translate(width / 2, height / 2)
|
|
.scale(1.5)
|
|
.translate(-foundNode.y, -foundNode.x);
|
|
|
|
d3.select("svg").transition().duration(750).call(zoom.transform, transform);
|
|
|
|
// Highlight the found node
|
|
setTimeout(() => {
|
|
svg.selectAll('circle.node')
|
|
.style("stroke", d => d === foundNode ? "var(--hh-accent)" : "var(--hh-primary)")
|
|
.style("stroke-width", d => d === foundNode ? "4px" : "2px");
|
|
}, 750);
|
|
|
|
alert(`Found: ${foundNode.data.name}`);
|
|
} else {
|
|
alert('Staff member not found');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Layout change
|
|
document.getElementById('layoutSelect').addEventListener('change', function(e) {
|
|
currentLayout = e.target.value;
|
|
|
|
// Update tree layout based on orientation
|
|
if (currentLayout === 'horizontal') {
|
|
treeLayout = d3.tree().nodeSize([50, 200]);
|
|
} else if (currentLayout === 'vertical') {
|
|
treeLayout = d3.tree().nodeSize([200, 50]);
|
|
} else {
|
|
treeLayout = d3.tree().nodeSize([50, 200]);
|
|
}
|
|
|
|
update(root);
|
|
centerView();
|
|
});
|
|
|
|
// Node size change
|
|
document.getElementById('nodeSizeSelect').addEventListener('change', function(e) {
|
|
nodeSizeBy = e.target.value;
|
|
update(root);
|
|
});
|
|
|
|
// Expand all
|
|
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);
|
|
});
|
|
|
|
// Collapse all
|
|
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);
|
|
});
|
|
|
|
// Reset zoom
|
|
document.getElementById('resetZoom').addEventListener('click', function() {
|
|
centerView();
|
|
});
|
|
|
|
// Initialize tree layout
|
|
treeLayout = d3.tree().nodeSize([50, 200]);
|
|
|
|
// Resize handler
|
|
window.addEventListener('resize', function() {
|
|
width = document.getElementById('hierarchy-chart').offsetWidth - margin.left - margin.right;
|
|
d3.select("svg")
|
|
.attr("width", width + margin.left + margin.right);
|
|
if (root) update(root);
|
|
});
|
|
});
|
|
</script>
|
|
{% endblock %}
|