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 `
${name}
`; }); // 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 ? `` : ''; }); // 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 ? `` : ``; }); } 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 (
{/* Loading state */} {isLoading && (
{loadingText}
)} {/* Error state */} {loadingError && (
!
{errorText}
)}
); }; export default EcoTree;