693 lines
24 KiB
JavaScript
693 lines
24 KiB
JavaScript
import React, { useRef, useEffect, useCallback } from 'react';
|
|
import * as d3 from 'd3';
|
|
import './EcoTree.css';
|
|
|
|
/**
|
|
* EcoTree React组件 - 从Vue版本转换而来
|
|
* 使用D3.js绘制可交互的树形图
|
|
*/
|
|
const EcoTree = ({
|
|
// Tree data in hierarchical format
|
|
treeData,
|
|
// Custom icons for nodes at different levels
|
|
nodeIcons = {},
|
|
// Initial expanded nodes
|
|
initialExpandedNodes = [],
|
|
// Whether to expand all nodes initially
|
|
expandAll = false,
|
|
// Whether to allow clicking on nodes to expand/collapse
|
|
expandOnClickNode = false,
|
|
// Custom node colors based on depth
|
|
nodeColors = {},
|
|
// Custom node sizes based on depth
|
|
nodeSizes = {},
|
|
// Custom node text colors based on depth
|
|
nodeTextColors = {},
|
|
// Custom node text sizes based on depth
|
|
nodeTextSizes = {},
|
|
// Allow customizing background colors
|
|
backgroundColor = '#f5f6f9',
|
|
// Whether the component is in loading state
|
|
isLoading = false,
|
|
// Loading state text
|
|
loadingText = '正在加载数据...',
|
|
// Error state
|
|
loadingError = false,
|
|
// Error text
|
|
errorText = '加载失败,请稍后重试',
|
|
// 控制节点之间的水平间距
|
|
horizontalNodeSpace = 320,
|
|
// 控制根节点到第一级节点的间距
|
|
rootLevelNodeSpace = 320,
|
|
// 是否显示第一级节点的颜色块
|
|
showLevel1ColorBlock = true,
|
|
// 控制叶子节点到其父节点的间距
|
|
leafNodeSpace = null,
|
|
// 控制不同层级节点之间的间距配置
|
|
levelNodeSpaces = {},
|
|
// 事件回调
|
|
onNodeClick,
|
|
onNodeToggle,
|
|
onRetry,
|
|
onUpdateExpandedNodes
|
|
}) => {
|
|
const treeContainer = useRef(null);
|
|
const canvasContextRef = useRef(null);
|
|
const currentHierarchyRootRef = useRef(null);
|
|
const nodeStatesRef = useRef(new Map());
|
|
const resizeObserverRef = useRef(null);
|
|
|
|
// Helper Functions
|
|
const getNodeColor = useCallback((depth) => {
|
|
if (nodeColors && nodeColors[depth] !== undefined) {
|
|
return nodeColors[depth];
|
|
}
|
|
|
|
// Default colors
|
|
switch (depth) {
|
|
case 0: return '#cfedff'; // Root - Light Blue
|
|
case 1: return '#43a047'; // Level 1 - Green
|
|
case 2: return '#fb8c00'; // Level 2 - Orange
|
|
case 3: return '#8e24aa'; // Level 3 - Purple
|
|
default: return '#a0a0a0'; // Level 4+ - Gray
|
|
}
|
|
}, [nodeColors]);
|
|
|
|
const getNodeSize = useCallback((depth) => {
|
|
if (nodeSizes && nodeSizes[depth] !== undefined) {
|
|
return nodeSizes[depth];
|
|
}
|
|
|
|
// Default sizes
|
|
switch (depth) {
|
|
case 0: return 8;
|
|
case 1: return 7;
|
|
case 2: return 6;
|
|
default: return 5;
|
|
}
|
|
}, [nodeSizes]);
|
|
|
|
const getNodeTextColor = useCallback((depth) => {
|
|
if (nodeTextColors && nodeTextColors[depth] !== undefined) {
|
|
return nodeTextColors[depth];
|
|
}
|
|
return depth > 0 ? '#ffffff' : '#4c5767';
|
|
}, [nodeTextColors]);
|
|
|
|
const getNodeTextSize = useCallback((depth) => {
|
|
if (nodeTextSizes && nodeTextSizes[depth] !== undefined) {
|
|
return nodeTextSizes[depth];
|
|
}
|
|
|
|
if (depth === 0) {
|
|
return '20px';
|
|
}
|
|
return ['19px', '18px', '16px'][depth - 1] || '16px';
|
|
}, [nodeTextSizes]);
|
|
|
|
const safeNumber = useCallback((value, fallback = 0) => {
|
|
return (value === undefined || isNaN(value)) ? fallback : value;
|
|
}, []);
|
|
|
|
// Text Processing Functions
|
|
const getTextWidth = useCallback((text, fontStyle) => {
|
|
if (!canvasContextRef.current) {
|
|
const canvas = document.createElement('canvas');
|
|
canvasContextRef.current = canvas.getContext('2d');
|
|
}
|
|
canvasContextRef.current.font = fontStyle;
|
|
return canvasContextRef.current.measureText(text || '').width;
|
|
}, []);
|
|
|
|
const truncateText = useCallback((text, maxWidth, fontStyle) => {
|
|
if (!text) return '';
|
|
|
|
if (!canvasContextRef.current) {
|
|
const canvas = document.createElement('canvas');
|
|
canvasContextRef.current = canvas.getContext('2d');
|
|
}
|
|
|
|
canvasContextRef.current.font = fontStyle;
|
|
const ellipsis = '...';
|
|
const ellipsisWidth = canvasContextRef.current.measureText(ellipsis).width;
|
|
|
|
if (canvasContextRef.current.measureText(text).width <= maxWidth) {
|
|
return text;
|
|
}
|
|
|
|
const targetWidth = maxWidth - ellipsisWidth;
|
|
let truncatedText = text;
|
|
|
|
while (truncatedText.length > 0) {
|
|
truncatedText = truncatedText.slice(0, -1);
|
|
if (canvasContextRef.current.measureText(truncatedText).width <= targetWidth) {
|
|
return truncatedText + ellipsis;
|
|
}
|
|
}
|
|
|
|
return ellipsis;
|
|
}, []);
|
|
|
|
// Main render function
|
|
const renderTree = useCallback(() => {
|
|
if (!treeContainer.current || !currentHierarchyRootRef.current) return;
|
|
|
|
try {
|
|
// Clear existing visualization
|
|
d3.select(treeContainer.current).html('');
|
|
|
|
// Constants for layout
|
|
const rectHeight = 36;
|
|
const textPaddingLeft = 44;
|
|
const textPaddingRight = 28;
|
|
|
|
const containerWidth = treeContainer.current.clientWidth;
|
|
|
|
// Icon dimensions
|
|
const iconWidth = 30;
|
|
const iconHeight = 30;
|
|
|
|
// Toggle button dimensions
|
|
const toggleButtonRadius = 12;
|
|
const toggleButtonGap = 10;
|
|
|
|
// Calculate node widths and store in Map
|
|
const nodeVisualWidths = new Map();
|
|
|
|
// Apply expanded/collapsed state
|
|
currentHierarchyRootRef.current.descendants().forEach(node => {
|
|
const nodeId = node.id || node.data.code;
|
|
const state = nodeId ? nodeStatesRef.current.get(nodeId) : undefined;
|
|
const shouldBeExpanded = state !== undefined
|
|
? state.expanded
|
|
: (node.depth <= 1 || expandAll);
|
|
|
|
if (node.depth > 0) {
|
|
if (!shouldBeExpanded) {
|
|
if (node.children) {
|
|
node._children = node.children;
|
|
node.children = null;
|
|
}
|
|
} else {
|
|
if (node._children) {
|
|
node.children = node._children;
|
|
node._children = null;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Calculate tree layout
|
|
const treeLayout = d3.tree().nodeSize([60, horizontalNodeSpace]);
|
|
treeLayout(currentHierarchyRootRef.current);
|
|
|
|
// Adjust node positions
|
|
currentHierarchyRootRef.current.descendants().forEach(node => {
|
|
if (node.parent) {
|
|
node.originalY = node.y;
|
|
node.y += 20;
|
|
|
|
// Handle level spacing
|
|
let levelSpacingApplied = false;
|
|
if (levelNodeSpaces && Object.keys(levelNodeSpaces).length > 0) {
|
|
const levelKey = `${node.parent.depth}-${node.depth}`;
|
|
if (levelNodeSpaces[levelKey] !== undefined) {
|
|
if (node.parent.depth === 0) {
|
|
const rootOuterSize = Math.max(95 * (safeNumber(getNodeSize(0), 8) / 8), 75);
|
|
node.y = rootOuterSize + levelNodeSpaces[levelKey];
|
|
levelSpacingApplied = true;
|
|
} else {
|
|
const parentY = node.parent.y;
|
|
const parentId = node.parent.id || node.parent.data.code;
|
|
const parentWidth = nodeVisualWidths.get(parentId) || node.parent._calculatedRectWidth || 0;
|
|
const spacingStartY = parentY + parentWidth;
|
|
node.y = spacingStartY + levelNodeSpaces[levelKey];
|
|
levelSpacingApplied = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!levelSpacingApplied) {
|
|
if (node.parent.depth === 0 && node.depth === 1) {
|
|
const rootOuterSize = Math.max(95 * (safeNumber(getNodeSize(0), 8) / 8), 75);
|
|
node.y = Math.max(rootLevelNodeSpace, rootOuterSize);
|
|
}
|
|
}
|
|
|
|
// Handle leaf nodes
|
|
const isLeafNode = !node.children && !node._children;
|
|
if (leafNodeSpace !== null && isLeafNode) {
|
|
if (node.parent) {
|
|
const parentY = node.parent.y;
|
|
const parentId = node.parent.id || node.parent.data.code;
|
|
const parentWidth = nodeVisualWidths.get(parentId) || node.parent._calculatedRectWidth || 0;
|
|
const minSpacing = 30;
|
|
const spacing = Math.max(leafNodeSpace, minSpacing);
|
|
node.y = parentY + parentWidth + spacing;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Filter visible nodes
|
|
const visibleNodes = currentHierarchyRootRef.current.descendants().filter(node => {
|
|
let current = node;
|
|
while (current.parent) {
|
|
if (current.parent.children?.indexOf(current) === -1) {
|
|
return false;
|
|
}
|
|
current = current.parent;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Calculate node widths
|
|
visibleNodes.forEach(node => {
|
|
const fontSize = getNodeTextSize(node.depth);
|
|
const fontWeight = node.depth < 2 ? 'bold' : 'normal';
|
|
const fontFamily = fontWeight === 'bold'
|
|
? 'Alibaba-PuHuiTi-SemiBold, sans-serif'
|
|
: 'Alibaba-PuHuiTi-Regular, sans-serif';
|
|
const fontStyle = `${fontWeight} ${fontSize} ${fontFamily}`;
|
|
|
|
const actualTextWidth = getTextWidth(node.data.name || '', fontStyle);
|
|
const hasColorBlock = node.depth === 1;
|
|
const colorBlockWidth = hasColorBlock ? 10 : 0;
|
|
const colorBlockPadding = hasColorBlock ? 5 : 0;
|
|
|
|
const calculatedWidth = textPaddingLeft + actualTextWidth + textPaddingRight + colorBlockWidth + colorBlockPadding;
|
|
|
|
node._calculatedRectWidth = calculatedWidth;
|
|
node._textAvailableWidth = calculatedWidth - textPaddingLeft - (node.depth === 1 ? 10 + 5 : 0) - textPaddingRight + 10;
|
|
|
|
const nodeId = node.id || node.data.code;
|
|
if (nodeId) {
|
|
nodeVisualWidths.set(nodeId, calculatedWidth);
|
|
}
|
|
});
|
|
|
|
// Calculate boundaries
|
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
|
|
visibleNodes.forEach(node => {
|
|
minX = Math.min(minX, node.x);
|
|
maxX = Math.max(maxX, node.x);
|
|
minY = Math.min(minY, node.y);
|
|
maxY = Math.max(maxY, node.y);
|
|
maxY = Math.max(maxY, node.y + (nodeVisualWidths.get(node.id || node.data.code) || node._calculatedRectWidth || 0) + 30);
|
|
});
|
|
|
|
// Add padding
|
|
const padding = { top: 100, right: 20, bottom: 100, left: 120 };
|
|
|
|
// Calculate SVG dimensions
|
|
const realWidth = maxY - minY + padding.left + padding.right;
|
|
const realHeight = maxX - minX + padding.top + padding.bottom;
|
|
|
|
const containerHeight = treeContainer.current.clientHeight;
|
|
const containerAvailableWidth = containerWidth - 20;
|
|
let scaleFactor = 1;
|
|
let needsScaling = false;
|
|
|
|
if (realWidth > containerAvailableWidth) {
|
|
scaleFactor = containerAvailableWidth / realWidth;
|
|
needsScaling = true;
|
|
}
|
|
|
|
const finalHeight = realHeight * (needsScaling ? scaleFactor : 1);
|
|
const useFullHeight = finalHeight < containerHeight;
|
|
|
|
// Create SVG
|
|
const svg = d3.select(treeContainer.current)
|
|
.append('svg')
|
|
.attr('width', '100%')
|
|
.attr('height', useFullHeight ? '100%' : finalHeight)
|
|
.attr('viewBox', `0 0 ${realWidth} ${realHeight}`)
|
|
.attr('preserveAspectRatio', 'xMinYMid meet')
|
|
.style('display', 'block')
|
|
.style('max-height', '100%');
|
|
|
|
// Add gradients
|
|
const defs = svg.append('defs');
|
|
|
|
const incrementGradient = defs.append('linearGradient')
|
|
.attr('id', 'increment-gradient')
|
|
.attr('x1', '0%').attr('y1', '0%')
|
|
.attr('x2', '100%').attr('y2', '0%');
|
|
incrementGradient.append('stop').attr('offset', '0%').attr('stop-color', '#E3F5FF');
|
|
incrementGradient.append('stop').attr('offset', '100%').attr('stop-color', '#FF8989');
|
|
|
|
const stockGradient = defs.append('linearGradient')
|
|
.attr('id', 'stock-gradient')
|
|
.attr('x1', '0%').attr('y1', '0%')
|
|
.attr('x2', '100%').attr('y2', '0%');
|
|
stockGradient.append('stop').attr('offset', '0%').attr('stop-color', '#E3F5FF');
|
|
stockGradient.append('stop').attr('offset', '100%').attr('stop-color', '#7ABAFF');
|
|
|
|
const declineGradient = defs.append('linearGradient')
|
|
.attr('id', 'decline-gradient')
|
|
.attr('x1', '0%').attr('y1', '0%')
|
|
.attr('x2', '100%').attr('y2', '0%');
|
|
declineGradient.append('stop').attr('offset', '0%').attr('stop-color', '#E3F5FF');
|
|
declineGradient.append('stop').attr('offset', '100%').attr('stop-color', '#FFBD7F');
|
|
|
|
// Main group
|
|
const mainGroup = svg.append('g')
|
|
.attr('transform', `translate(${padding.left - minY}, ${padding.top - minX})`);
|
|
|
|
// Draw links
|
|
mainGroup.selectAll('.tree-view-link')
|
|
.data(visibleNodes.filter(d => d.parent))
|
|
.enter().append('path')
|
|
.attr('class', 'tree-view-link')
|
|
.attr('d', d => {
|
|
const parent = d.parent;
|
|
const sourceX = parent.x;
|
|
const targetX = d.x;
|
|
const targetY = d.y;
|
|
let sourceY;
|
|
|
|
if (parent.depth === 0) {
|
|
sourceY = parent.y + 95;
|
|
} else {
|
|
const parentId = parent.id || parent.data.code;
|
|
const parentWidth = safeNumber(nodeVisualWidths.get(parentId) || parent._calculatedRectWidth || 0, 0);
|
|
sourceY = parent.y + parentWidth + toggleButtonGap + toggleButtonRadius;
|
|
}
|
|
|
|
return `M ${sourceY},${sourceX}
|
|
C ${(sourceY + targetY) / 2},${sourceX}
|
|
${(sourceY + targetY) / 2},${targetX}
|
|
${targetY},${targetX}`;
|
|
})
|
|
.attr('stroke', '#ccc')
|
|
.attr('stroke-width', '1.2px')
|
|
.attr('fill', 'none');
|
|
|
|
// Draw nodes
|
|
const node = mainGroup.selectAll('.tree-view-node')
|
|
.data(visibleNodes, d => d.id || d.data.code || `${d.depth}-${d.data.name}`)
|
|
.enter().append('g')
|
|
.attr('class', d => `tree-view-node depth-${d.depth}`)
|
|
.attr('transform', d => `translate(${d.y}, ${d.x})`);
|
|
|
|
// Root nodes
|
|
const rootNodeGroup = node.filter(d => d.depth === 0);
|
|
|
|
// Root outer circle
|
|
rootNodeGroup.append('circle')
|
|
.attr('class', 'tree-view-root-outer')
|
|
.attr('r', d => {
|
|
const baseSize = 95;
|
|
const configSize = safeNumber(getNodeSize(0), 8);
|
|
return Math.max(baseSize * (configSize / 8), 75);
|
|
})
|
|
.attr('fill', backgroundColor)
|
|
.attr('cx', 0)
|
|
.attr('cy', 0);
|
|
|
|
// Root inner circle
|
|
rootNodeGroup.append('circle')
|
|
.attr('class', 'tree-view-root-inner')
|
|
.attr('r', d => {
|
|
const baseSize = 75;
|
|
const configSize = safeNumber(getNodeSize(0), 8);
|
|
return Math.max(baseSize * (configSize / 8), 60);
|
|
})
|
|
.attr('fill', d => getNodeColor(0))
|
|
.attr('cx', 0)
|
|
.attr('cy', 0);
|
|
|
|
// Root node text
|
|
rootNodeGroup.append('foreignObject')
|
|
.attr('x', -60)
|
|
.attr('y', -25)
|
|
.attr('width', 120)
|
|
.attr('height', 50)
|
|
.attr('pointer-events', 'none')
|
|
.html(d => {
|
|
const name = d.data.name || '';
|
|
const textColor = getNodeTextColor(d.depth);
|
|
const fontSize = getNodeTextSize(d.depth);
|
|
return `<div style="display: flex; align-items: center; justify-content: center; height: 100%; text-align: center;
|
|
font-size: ${fontSize}; font-weight: bold; color: ${textColor}; font-family: 'Alibaba-PuHuiTi-SemiBold', sans-serif;
|
|
overflow-wrap: break-word; line-height: 1.2;">
|
|
${name}
|
|
</div>`;
|
|
});
|
|
|
|
// Non-root nodes
|
|
const nonRootNodeGroups = node.filter(d => d.depth > 0);
|
|
|
|
// Left marker circle
|
|
nonRootNodeGroups.append('circle')
|
|
.attr('class', 'tree-view-node-marker tree-view-left-marker')
|
|
.attr('r', d => getNodeSize(d.depth))
|
|
.attr('cx', rectHeight / 2)
|
|
.attr('cy', 0)
|
|
.attr('fill', d => getNodeColor(d.depth))
|
|
.attr('stroke', '#fff')
|
|
.attr('stroke-width', 1.5);
|
|
|
|
// Node background
|
|
nonRootNodeGroups.append('path')
|
|
.attr('class', 'tree-view-label-bg')
|
|
.attr('d', d => {
|
|
const w = nodeVisualWidths.get(d.id || d.data.code) || d._calculatedRectWidth || 0;
|
|
const h = rectHeight;
|
|
const r = h / 2;
|
|
const rr = 10;
|
|
|
|
return `M ${w - rr},${-h / 2}
|
|
L ${r},${-h / 2}
|
|
A ${r},${r} 0 0 0 ${r},${h / 2}
|
|
L ${w - rr},${h / 2}
|
|
A ${rr},${rr} 0 0 0 ${w},${h / 2 - rr}
|
|
L ${w},${-h / 2 + rr}
|
|
A ${rr},${rr} 0 0 0 ${w - rr},${-h / 2}
|
|
Z`;
|
|
})
|
|
.attr('fill', d => {
|
|
if (d.depth === 1 && d.data.tag) {
|
|
const gradientMap = {
|
|
'增量行业': 'url(#increment-gradient)',
|
|
'存量行业': 'url(#stock-gradient)',
|
|
'衰落行业': 'url(#decline-gradient)'
|
|
};
|
|
return gradientMap[d.data.tag] || backgroundColor;
|
|
}
|
|
return backgroundColor;
|
|
})
|
|
.style('cursor', 'pointer')
|
|
.on('click', (event, d) => {
|
|
if (onNodeClick) {
|
|
onNodeClick({
|
|
node: d.data,
|
|
level: d.depth + 1,
|
|
event
|
|
});
|
|
}
|
|
|
|
if (expandOnClickNode && (d.children || d._children)) {
|
|
event.stopPropagation();
|
|
handleToggleNode(d);
|
|
}
|
|
});
|
|
|
|
// Node icon
|
|
nonRootNodeGroups.append('foreignObject')
|
|
.attr('class', 'tree-view-node-icon')
|
|
.attr('x', 0)
|
|
.attr('y', -(iconHeight / 2))
|
|
.attr('width', iconWidth)
|
|
.attr('height', iconHeight)
|
|
.style('pointer-events', 'none')
|
|
.html(d => {
|
|
let icon = '';
|
|
if (nodeIcons && nodeIcons[d.depth]) {
|
|
icon = nodeIcons[d.depth];
|
|
}
|
|
return icon ? `<img src="${icon}" width="${iconWidth}" height="${iconHeight}" style="display: block;" />` : '';
|
|
});
|
|
|
|
// Node label text
|
|
nonRootNodeGroups.append('text')
|
|
.attr('class', 'tree-view-node-label')
|
|
.attr('dy', '.35em')
|
|
.attr('y', 0)
|
|
.attr('x', d => textPaddingLeft + 3)
|
|
.attr('text-anchor', 'start')
|
|
.text(d => {
|
|
const fontSize = getNodeTextSize(d.depth);
|
|
const fontWeight = d.depth < 2 ? 'bold' : 'normal';
|
|
const fontFamily = fontWeight === 'bold'
|
|
? 'Alibaba-PuHuiTi-SemiBold, sans-serif'
|
|
: 'Alibaba-PuHuiTi-Regular, sans-serif';
|
|
const fontStyle = `${fontWeight} ${fontSize} ${fontFamily}`;
|
|
return truncateText(d.data.name, d._textAvailableWidth, fontStyle);
|
|
})
|
|
.style('font-size', d => getNodeTextSize(d.depth))
|
|
.style('font-weight', d => d.depth < 2 ? 'bold' : 'normal')
|
|
.attr('fill', d => getNodeTextColor(d.depth))
|
|
.style('pointer-events', 'none');
|
|
|
|
// Node tooltips
|
|
nonRootNodeGroups.append('title')
|
|
.text(d => d.data.name);
|
|
|
|
// Toggle buttons
|
|
const toggleNodes = nonRootNodeGroups.filter(d => d.children || d._children);
|
|
|
|
toggleNodes.append('circle')
|
|
.attr('class', 'tree-view-toggle-bg')
|
|
.attr('r', toggleButtonRadius)
|
|
.attr('cx', d => safeNumber((nodeVisualWidths.get(d.id || d.data.code) || d._calculatedRectWidth || 0) + toggleButtonGap + toggleButtonRadius, toggleButtonGap + toggleButtonRadius))
|
|
.attr('cy', 0)
|
|
.attr('fill', d => getNodeColor(d.depth))
|
|
.attr('stroke', '#fff')
|
|
.attr('stroke-width', 1.5)
|
|
.style('cursor', 'pointer')
|
|
.on('click', (event, d) => {
|
|
event.stopPropagation();
|
|
handleToggleNode(d);
|
|
});
|
|
|
|
// Toggle icons
|
|
toggleNodes.append('foreignObject')
|
|
.attr('class', 'tree-view-toggle-icon-fo')
|
|
.attr('x', d => safeNumber((nodeVisualWidths.get(d.id || d.data.code) || d._calculatedRectWidth || 0) + toggleButtonGap + toggleButtonRadius - 8, toggleButtonGap + toggleButtonRadius - 8))
|
|
.attr('y', -8)
|
|
.attr('width', 16)
|
|
.attr('height', 16)
|
|
.style('pointer-events', 'none')
|
|
.html(d => {
|
|
const nodeId = d.id || d.data.code;
|
|
if (!nodeId) return '';
|
|
|
|
const currentState = nodeStatesRef.current.get(nodeId);
|
|
const isExpanded = currentState !== undefined ? currentState.expanded : (d.children != null);
|
|
|
|
return isExpanded ?
|
|
`<svg viewBox="0 0 24 24" fill="white" width="16" height="16"><path d="M19 13H5v-2h14v2z"/></svg>` :
|
|
`<svg viewBox="0 0 24 24" fill="white" width="16" height="16"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`;
|
|
});
|
|
|
|
} catch (error) {
|
|
// Error handled silently
|
|
}
|
|
}, [
|
|
backgroundColor, nodeColors, nodeSizes, nodeTextColors, nodeTextSizes,
|
|
expandAll, nodeIcons, horizontalNodeSpace, rootLevelNodeSpace,
|
|
leafNodeSpace, levelNodeSpaces, expandOnClickNode, onNodeClick,
|
|
getNodeColor, getNodeSize, getNodeTextColor, getNodeTextSize,
|
|
safeNumber, getTextWidth, truncateText
|
|
]);
|
|
|
|
// Handle toggle node
|
|
const handleToggleNode = useCallback((d) => {
|
|
const nodeId = d.id || d.data.code;
|
|
if (!nodeId) return;
|
|
|
|
const currentState = nodeStatesRef.current.get(nodeId);
|
|
const currentlyExpanded = currentState !== undefined ? currentState.expanded : (d.children != null);
|
|
|
|
const newState = { expanded: !currentlyExpanded };
|
|
nodeStatesRef.current.set(nodeId, newState);
|
|
|
|
if (onUpdateExpandedNodes) {
|
|
const expandedNodesList = [];
|
|
nodeStatesRef.current.forEach((state, id) => {
|
|
if (state.expanded) {
|
|
expandedNodesList.push(id);
|
|
}
|
|
});
|
|
onUpdateExpandedNodes(expandedNodesList);
|
|
}
|
|
|
|
if (onNodeToggle) {
|
|
onNodeToggle({
|
|
node: d.data,
|
|
expanded: !currentlyExpanded
|
|
});
|
|
}
|
|
|
|
renderTree();
|
|
}, [onUpdateExpandedNodes, onNodeToggle, renderTree]);
|
|
|
|
// Initialize component
|
|
useEffect(() => {
|
|
if (initialExpandedNodes && initialExpandedNodes.length > 0) {
|
|
initialExpandedNodes.forEach(nodeId => {
|
|
nodeStatesRef.current.set(nodeId, { expanded: true });
|
|
});
|
|
}
|
|
|
|
if (treeData) {
|
|
currentHierarchyRootRef.current = d3.hierarchy(treeData, d => d.children);
|
|
renderTree();
|
|
}
|
|
}, [treeData, initialExpandedNodes, renderTree]);
|
|
|
|
// Handle resize
|
|
useEffect(() => {
|
|
const handleResize = () => {
|
|
if (treeContainer.current) {
|
|
renderTree();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
if (typeof ResizeObserver !== 'undefined' && treeContainer.current) {
|
|
resizeObserverRef.current = new ResizeObserver(() => {
|
|
renderTree();
|
|
});
|
|
resizeObserverRef.current.observe(treeContainer.current);
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
if (resizeObserverRef.current) {
|
|
resizeObserverRef.current.disconnect();
|
|
}
|
|
};
|
|
}, [renderTree]);
|
|
|
|
// Watch for prop changes that require re-render
|
|
useEffect(() => {
|
|
if (currentHierarchyRootRef.current && treeContainer.current) {
|
|
renderTree();
|
|
}
|
|
}, [
|
|
backgroundColor, nodeColors, nodeSizes, nodeTextColors, nodeTextSizes,
|
|
expandAll, nodeIcons, horizontalNodeSpace, rootLevelNodeSpace,
|
|
leafNodeSpace, levelNodeSpaces, renderTree
|
|
]);
|
|
|
|
return (
|
|
<div className="tree-view-container">
|
|
<div className="tree-view-scroll-wrapper">
|
|
<div ref={treeContainer} className="tree-view-tree-chart"></div>
|
|
</div>
|
|
|
|
{/* Loading state */}
|
|
{isLoading && (
|
|
<div className="chart-loading">
|
|
<div className="loading-spinner"></div>
|
|
<div className="loading-text">{loadingText}</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error state */}
|
|
{loadingError && (
|
|
<div className="chart-error">
|
|
<div className="error-icon">!</div>
|
|
<div className="error-text">{errorText}</div>
|
|
<button className="retry-button" onClick={onRetry}>重新加载</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default EcoTree; |