feat: integrate d3-org-chart for network visualization and add SignalR token notification service
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* d3-org-chart custom styles for FourSat
|
||||
* Binary Network Tree visualization
|
||||
*/
|
||||
|
||||
/* Container */
|
||||
.org-chart-wrapper {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.org-chart-container {
|
||||
width: 100%;
|
||||
height: 550px;
|
||||
}
|
||||
|
||||
/* Loading & Error States */
|
||||
.chart-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.no-data-message,
|
||||
.error-message {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #6c757d;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
/* Node Card - Compact */
|
||||
.org-node-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e0e0e0;
|
||||
font-family: 'Vazirmatn', 'IRANSans', sans-serif;
|
||||
direction: rtl;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.org-node-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border-color: #0380C0;
|
||||
}
|
||||
|
||||
.org-node-card.active {
|
||||
border-left: 3px solid #28a745;
|
||||
}
|
||||
|
||||
.org-node-card.inactive {
|
||||
opacity: 0.6;
|
||||
border-left: 3px solid #dc3545;
|
||||
}
|
||||
|
||||
/* Compact Header */
|
||||
.node-header-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #fff 100%);
|
||||
}
|
||||
|
||||
.node-header-compact.root {
|
||||
background: linear-gradient(135deg, #e8f5e9 0%, #fff 100%);
|
||||
}
|
||||
|
||||
.node-header-compact.root .node-avatar-sm {
|
||||
background: linear-gradient(135deg, #28a745 0%, #1e7e34 100%);
|
||||
}
|
||||
|
||||
/* Avatar Small */
|
||||
.node-avatar-sm {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
min-width: 26px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #0380C0 0%, #026a9e 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.node-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.node-name-sm {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.node-level-sm {
|
||||
font-size: 9px;
|
||||
color: #6c757d;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Legacy Avatar (keep for compatibility) */
|
||||
.node-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Position Badge */
|
||||
.position-badge {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.position-badge.position-left {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.position-badge.position-right {
|
||||
background: #fce4ec;
|
||||
color: #c2185b;
|
||||
}
|
||||
|
||||
.position-badge.position-root {
|
||||
background: #e8f5e9;
|
||||
color: #388e3c;
|
||||
}
|
||||
|
||||
/* Node Body */
|
||||
.node-body {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.node-mobile {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
direction: ltr;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.node-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: #f8f9fa;
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.club-badge {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Expand/Collapse Button */
|
||||
.org-expand-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #0380C0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 6px rgba(3, 128, 192, 0.4);
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.org-expand-btn:hover {
|
||||
background: #026a9e;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.org-chart-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 10px 12px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #dee2e6;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #adb5bd;
|
||||
}
|
||||
|
||||
.toolbar-btn.primary {
|
||||
background: #0380C0;
|
||||
color: white;
|
||||
border-color: #0380C0;
|
||||
}
|
||||
|
||||
.toolbar-btn.primary:hover {
|
||||
background: #026a9e;
|
||||
}
|
||||
|
||||
/* Stats Bar */
|
||||
.org-chart-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding: 12px 16px;
|
||||
background: #f8f9fa;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.stat-value.left {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.stat-value.right {
|
||||
color: #c2185b;
|
||||
}
|
||||
|
||||
/* Depth Controls */
|
||||
.depth-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.depth-control label {
|
||||
font-size: 13px;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.depth-control select {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.org-chart-wrapper {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.org-chart-container {
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
.org-chart-toolbar {
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.org-chart-stats {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.org-chart-container {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.org-chart-toolbar {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.toolbar-btn span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.org-chart-toolbar,
|
||||
.org-chart-stats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.org-chart-container {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* d3-org-chart integration for Blazor
|
||||
* Network Binary Tree visualization for FourSat
|
||||
*/
|
||||
|
||||
window.OrgChart = {
|
||||
chart: null,
|
||||
dotNetHelper: null,
|
||||
|
||||
/**
|
||||
* Initialize the organization chart
|
||||
* @param {string} containerId - The ID of the container element
|
||||
* @param {object} data - The tree data in flat array format
|
||||
* @param {object} dotNetHelper - Blazor .NET helper for callbacks
|
||||
*/
|
||||
init: function (containerId, data, dotNetHelper) {
|
||||
this.dotNetHelper = dotNetHelper;
|
||||
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.error('OrgChart: Container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous chart
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
container.innerHTML = '<div class="no-data-message">دادهای برای نمایش وجود ندارد</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.chart = new d3.OrgChart()
|
||||
.container('#' + containerId)
|
||||
.data(data)
|
||||
.nodeWidth((d) => 130)
|
||||
.nodeHeight((d) => 56)
|
||||
.childrenMargin((d) => 50)
|
||||
.compactMarginBetween((d) => 15)
|
||||
.compactMarginPair((d) => 15)
|
||||
.neighbourMargin((a, b) => 15)
|
||||
.siblingsMargin((d) => 15)
|
||||
.buttonContent(({ node, state }) => {
|
||||
const hasChildren = node.data._directSubordinates > 0;
|
||||
const isExpanded = node.children;
|
||||
return hasChildren ? `<div class="org-expand-btn">
|
||||
<span>${isExpanded ? '−' : '+'}</span>
|
||||
</div>` : '';
|
||||
})
|
||||
.linkUpdate(function (d, i, arr) {
|
||||
d3.select(this)
|
||||
.attr('stroke', (d) => d.data._highlighted || d.data._upToTheRootHighlighted ? '#0380C0' : '#ccc')
|
||||
.attr('stroke-width', (d) => d.data._highlighted || d.data._upToTheRootHighlighted ? 3 : 2);
|
||||
})
|
||||
.nodeContent(function (d, i, arr, state) {
|
||||
const data = d.data;
|
||||
const isRoot = !data.parentId || data.parentId === '';
|
||||
const positionClass = data.position === 'Left' ? 'position-left' :
|
||||
data.position === 'Right' ? 'position-right' : 'position-root';
|
||||
const activeClass = data.isActive ? 'active' : 'inactive';
|
||||
|
||||
// Avatar - first letter of name
|
||||
const firstChar = data.fullName ? data.fullName.charAt(0) : '?';
|
||||
|
||||
return `
|
||||
<div class="org-node-card ${positionClass} ${activeClass}" data-user-id="${data.id}">
|
||||
<div class="node-header-compact ${isRoot ? 'root' : ''}">
|
||||
<div class="node-avatar-sm">${firstChar}</div>
|
||||
<div class="node-info">
|
||||
<div class="node-name-sm">${data.fullName || 'بدون نام'}</div>
|
||||
<div class="node-level-sm">L${data.level || 0}${!isRoot ? ' • ' + (data.position === 'Left' ? 'چپ' : 'راست') : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.onNodeClick((d) => {
|
||||
if (this.dotNetHelper) {
|
||||
// Convert string id to number for C# long
|
||||
const userId = parseInt(d.data.id, 10);
|
||||
this.dotNetHelper.invokeMethodAsync('OnNodeClicked', userId);
|
||||
}
|
||||
})
|
||||
.render();
|
||||
|
||||
// Initial centering and zoom
|
||||
this.chart.fit();
|
||||
|
||||
} catch (error) {
|
||||
console.error('OrgChart: Error initializing chart:', error);
|
||||
container.innerHTML = '<div class="error-message">خطا در بارگذاری نمودار</div>';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the chart with new data
|
||||
* @param {object} data - The new tree data
|
||||
*/
|
||||
update: function (data) {
|
||||
if (this.chart) {
|
||||
this.chart.data(data).render();
|
||||
this.chart.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Expand all nodes
|
||||
*/
|
||||
expandAll: function () {
|
||||
if (this.chart) {
|
||||
this.chart.expandAll().render();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Collapse all nodes
|
||||
*/
|
||||
collapseAll: function () {
|
||||
if (this.chart) {
|
||||
this.chart.collapseAll().render();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Center the chart
|
||||
*/
|
||||
center: function () {
|
||||
if (this.chart) {
|
||||
this.chart.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fit entire tree to screen (zoom out completely)
|
||||
*/
|
||||
fitToScreen: function () {
|
||||
if (this.chart) {
|
||||
this.chart.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Zoom to specific node
|
||||
* @param {string} nodeId - The ID of the node to zoom to
|
||||
*/
|
||||
zoomToNode: function (nodeId) {
|
||||
if (this.chart) {
|
||||
this.chart.setCentered(nodeId).render();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Highlight path to specific node
|
||||
* @param {string} nodeId - The ID of the node
|
||||
*/
|
||||
highlightNode: function (nodeId) {
|
||||
if (this.chart) {
|
||||
this.chart.setHighlighted(nodeId).render();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear highlighting
|
||||
*/
|
||||
clearHighlight: function () {
|
||||
if (this.chart) {
|
||||
this.chart.clearHighlighting().render();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Export chart as PNG
|
||||
*/
|
||||
exportPng: function () {
|
||||
if (this.chart) {
|
||||
this.chart.exportImg({ full: true });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Export chart as SVG
|
||||
*/
|
||||
exportSvg: function () {
|
||||
if (this.chart) {
|
||||
this.chart.exportSvg();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispose the chart
|
||||
*/
|
||||
dispose: function () {
|
||||
if (this.chart) {
|
||||
// d3-org-chart doesn't have built-in dispose, so we just clear the reference
|
||||
this.chart = null;
|
||||
this.dotNetHelper = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert hierarchical tree data to flat array format for d3-org-chart
|
||||
* @param {object} rootNode - The root node with nested children
|
||||
* @returns {array} - Flat array of nodes
|
||||
*/
|
||||
convertToFlatArray: function (rootNode) {
|
||||
if (!rootNode) return [];
|
||||
|
||||
const result = [];
|
||||
|
||||
function traverse(node, parentId) {
|
||||
const flatNode = {
|
||||
id: node.userId?.toString() || node.id?.toString(),
|
||||
parentId: parentId,
|
||||
fullName: node.fullName || '',
|
||||
mobile: node.mobile || '',
|
||||
avatar: node.avatar,
|
||||
position: node.position || 'Root',
|
||||
level: node.level || 0,
|
||||
isActive: node.isActive !== false,
|
||||
isClubActive: node.isClubActive || false,
|
||||
activationWeekNumber: node.activationWeekNumber,
|
||||
joinedAt: node.joinedAt
|
||||
};
|
||||
|
||||
result.push(flatNode);
|
||||
|
||||
// Process left child
|
||||
if (node.leftChild) {
|
||||
traverse(node.leftChild, flatNode.id);
|
||||
}
|
||||
|
||||
// Process right child
|
||||
if (node.rightChild) {
|
||||
traverse(node.rightChild, flatNode.id);
|
||||
}
|
||||
}
|
||||
|
||||
traverse(rootNode, '');
|
||||
return result;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user