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 `