diff --git a/src/views/Concept/components/HierarchyView.js b/src/views/Concept/components/HierarchyView.js index cae3bb51..21b65489 100644 --- a/src/views/Concept/components/HierarchyView.js +++ b/src/views/Concept/components/HierarchyView.js @@ -1,11 +1,12 @@ /** - * HierarchyView - 概念层级思维导图视图 + * HierarchyView - 概念层级热力图视图 * - * 使用 ECharts Tree 图表实现思维导图效果 + * 使用 ECharts Treemap 实现热力图效果 * 特性: - * 1. 默认只展示前两层(根节点 + lv1),点击节点展开下层 - * 2. 集成 /hierarchy/price 接口获取实时涨跌幅 - * 3. 支持径向布局和正交布局切换 + * 1. 炫酷的矩形树图/热力图展示 + * 2. 点击 lv1 进入 lv2,点击 lv2 进入 lv3,层层钻取 + * 3. 集成 /hierarchy/price 接口获取实时涨跌幅 + * 4. 支持面包屑导航返回上级 */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { @@ -18,7 +19,6 @@ import { Spinner, Center, Flex, - ButtonGroup, Button, useBreakpointValue, Tooltip, @@ -27,203 +27,63 @@ import { import ReactECharts from 'echarts-for-react'; import { FaLayerGroup, - FaSitemap, - FaProjectDiagram, FaExpand, FaCompress, - FaRedo, FaSync, + FaHome, + FaChevronRight, } from 'react-icons/fa'; import { logger } from '../../../utils/logger'; // 一级分类颜色映射 const LV1_COLORS = { - '人工智能': '#8B5CF6', - '半导体': '#3B82F6', - '机器人': '#10B981', - '消费电子': '#EC4899', - '智能驾驶与汽车': '#F97316', - '新能源与电力': '#22C55E', - '空天经济': '#06B6D4', - '国防军工': '#EF4444', - '政策与主题': '#F59E0B', - '周期与材料': '#6B7280', - '大消费': '#F472B6', - '数字经济与金融科技': '#6366F1', - '全球宏观与贸易': '#14B8A6', - '医药健康': '#84CC16', - '前沿科技': '#A855F7', + '人工智能': ['#8B5CF6', '#A78BFA', '#C4B5FD'], + '半导体': ['#3B82F6', '#60A5FA', '#93C5FD'], + '机器人': ['#10B981', '#34D399', '#6EE7B7'], + '消费电子': ['#EC4899', '#F472B6', '#F9A8D4'], + '智能驾驶与汽车': ['#F97316', '#FB923C', '#FDBA74'], + '新能源与电力': ['#22C55E', '#4ADE80', '#86EFAC'], + '空天经济': ['#06B6D4', '#22D3EE', '#67E8F9'], + '国防军工': ['#EF4444', '#F87171', '#FCA5A5'], + '政策与主题': ['#F59E0B', '#FBBF24', '#FCD34D'], + '周期与材料': ['#6B7280', '#9CA3AF', '#D1D5DB'], + '大消费': ['#F472B6', '#F9A8D4', '#FBCFE8'], + '数字经济与金融科技': ['#6366F1', '#818CF8', '#A5B4FC'], + '全球宏观与贸易': ['#14B8A6', '#2DD4BF', '#5EEAD4'], + '医药健康': ['#84CC16', '#A3E635', '#BEF264'], + '前沿科技': ['#A855F7', '#C084FC', '#D8B4FE'], }; -// 获取涨跌幅颜色 +// 获取涨跌幅颜色(红涨绿跌) const getChangeColor = (value) => { if (value === null || value === undefined) return '#9CA3AF'; - return value > 0 ? '#EF4444' : value < 0 ? '#22C55E' : '#9CA3AF'; + if (value > 3) return '#DC2626'; + if (value > 1) return '#EF4444'; + if (value > 0) return '#F87171'; + if (value < -3) return '#15803D'; + if (value < -1) return '#22C55E'; + if (value < 0) return '#4ADE80'; + return '#9CA3AF'; }; // 格式化涨跌幅 const formatChangePercent = (value) => { - if (value === null || value === undefined) return ''; + if (value === null || value === undefined) return '--'; const formatted = Math.abs(value).toFixed(2); return value > 0 ? `+${formatted}%` : value < 0 ? `-${formatted}%` : '0.00%'; }; -/** - * 将后端层级数据转换为 ECharts tree 格式 - * @param {Array} hierarchy - 层级结构数据 - * @param {Object} priceData - 涨跌幅数据 { lv1Map, lv2Map, lv3Map } - * @param {number} initialDepth - 初始展开深度 (1 = 只展示lv1, 2 = 展示到lv2) - */ -const transformToEChartsData = (hierarchy, priceData, initialDepth = 1) => { - if (!hierarchy || hierarchy.length === 0) return null; - - const { lv1Map = {}, lv2Map = {}, lv3Map = {} } = priceData || {}; - - // 根节点 - const root = { - name: '概念中心', - itemStyle: { - color: '#8B5CF6', - borderColor: '#7C3AED', - borderWidth: 3, - }, - label: { - fontSize: 14, - fontWeight: 'bold', - color: '#1F2937', - }, - children: hierarchy.map(lv1 => { - const lv1Price = lv1Map[lv1.name] || {}; - const lv1Color = LV1_COLORS[lv1.name] || '#8B5CF6'; - const changeColor = getChangeColor(lv1Price.avg_change_pct); - const changeStr = formatChangePercent(lv1Price.avg_change_pct); - - return { - name: lv1.name, - value: lv1.concept_count, - collapsed: initialDepth < 2, // 初始是否折叠 - data: { - type: 'lv1', - id: lv1.id, - name: lv1.name, - concept_count: lv1.concept_count, - avg_change_pct: lv1Price.avg_change_pct, - stock_count: lv1Price.stock_count, - }, - itemStyle: { - color: lv1Color, - borderColor: lv1Color, - borderWidth: 2, - }, - label: { - fontSize: 12, - fontWeight: 'bold', - color: '#1F2937', - formatter: () => { - let text = `${lv1.name}\n{count|${lv1.concept_count}个}`; - if (changeStr) { - text += `\n{change|${changeStr}}`; - } - return text; - }, - rich: { - count: { - fontSize: 10, - color: '#6B7280', - lineHeight: 16, - }, - change: { - fontSize: 11, - color: changeColor, - fontWeight: 'bold', - lineHeight: 16, - }, - }, - }, - children: lv1.children?.map(lv2 => { - const lv2Price = lv2Map[lv2.name] || {}; - const lv2ChangeColor = getChangeColor(lv2Price.avg_change_pct); - const lv2ChangeStr = formatChangePercent(lv2Price.avg_change_pct); - const hasLv3 = lv2.children && lv2.children.length > 0; - - return { - name: lv2.name, - value: lv2.concept_count, - collapsed: true, // lv2 默认折叠 - data: { - type: 'lv2', - id: lv2.id, - name: lv2.name, - parentLv1: lv1.name, - concept_count: lv2.concept_count, - avg_change_pct: lv2Price.avg_change_pct, - stock_count: lv2Price.stock_count, - }, - itemStyle: { - color: lv1Color, - opacity: 0.8, - }, - label: { - fontSize: 11, - color: '#374151', - formatter: () => { - let text = `${lv2.name}\n{count|(${lv2.concept_count})}`; - if (lv2ChangeStr) { - text += ` {change|${lv2ChangeStr}}`; - } - return text; - }, - rich: { - count: { - fontSize: 9, - color: '#9CA3AF', - }, - change: { - fontSize: 10, - color: lv2ChangeColor, - fontWeight: 'bold', - }, - }, - }, - children: hasLv3 ? lv2.children.map(lv3 => { - const lv3Price = lv3Map[lv3.name] || {}; - const lv3ChangeStr = formatChangePercent(lv3Price.avg_change_pct); - - return { - name: lv3.name, - value: lv3.concept_count, - collapsed: true, - data: { - type: 'lv3', - id: lv3.id, - name: lv3.name, - parentLv1: lv1.name, - parentLv2: lv2.name, - concept_count: lv3.concept_count, - concepts: lv3.concepts, - }, - itemStyle: { - color: lv1Color, - opacity: 0.6, - }, - label: { - fontSize: 10, - color: '#4B5563', - formatter: `${lv3.name} (${lv3.concept_count})${lv3ChangeStr ? ' ' + lv3ChangeStr : ''}`, - }, - }; - }) : undefined, - }; - }) || [], - }; - }), - }; - - return root; +// 获取 lv1 的颜色 +const getLv1Color = (name, index = 0) => { + const colors = LV1_COLORS[name]; + if (colors) return colors[Math.min(index, colors.length - 1)]; + // 默认颜色 + const defaultColors = ['#8B5CF6', '#A78BFA', '#C4B5FD']; + return defaultColors[Math.min(index, defaultColors.length - 1)]; }; /** - * 主组件:层级视图 + * 主组件:层级热力图视图 */ const HierarchyView = ({ apiBaseUrl, @@ -236,9 +96,14 @@ const HierarchyView = ({ const [priceData, setPriceData] = useState({ lv1Map: {}, lv2Map: {}, lv3Map: {} }); const [priceLoading, setPriceLoading] = useState(false); const [tradeDate, setTradeDate] = useState(null); - const [layout, setLayout] = useState('radial'); // 'radial' | 'orthogonal' const [isFullscreen, setIsFullscreen] = useState(false); + // 钻取状态 + const [currentLevel, setCurrentLevel] = useState('lv1'); // 'lv1' | 'lv2' | 'lv3' + const [currentLv1, setCurrentLv1] = useState(null); + const [currentLv2, setCurrentLv2] = useState(null); + const [breadcrumbs, setBreadcrumbs] = useState([{ label: '全部分类', level: 'root' }]); + const chartRef = useRef(null); const containerRef = useRef(null); const isMobile = useBreakpointValue({ base: true, md: false }); @@ -327,39 +192,145 @@ const HierarchyView = ({ } }, [hierarchy, fetchHierarchyPrice]); + // 根据当前层级获取显示数据 + const currentData = useMemo(() => { + const { lv1Map, lv2Map, lv3Map } = priceData; + + if (currentLevel === 'lv1') { + // 显示所有 lv1 + return hierarchy.map((lv1, index) => { + const price = lv1Map[lv1.name] || {}; + return { + name: lv1.name, + value: lv1.concept_count || 10, + id: lv1.id, + level: 'lv1', + concept_count: lv1.concept_count, + stock_count: price.stock_count, + avg_change_pct: price.avg_change_pct, + children: lv1.children, + itemStyle: { + color: getLv1Color(lv1.name, 0), + borderColor: '#fff', + borderWidth: 2, + }, + }; + }); + } + + if (currentLevel === 'lv2' && currentLv1) { + // 显示选中 lv1 下的 lv2 + const lv1Data = hierarchy.find(h => h.name === currentLv1.name); + if (!lv1Data || !lv1Data.children) return []; + + return lv1Data.children.map((lv2, index) => { + const price = lv2Map[lv2.name] || {}; + return { + name: lv2.name, + value: lv2.concept_count || 5, + id: lv2.id, + level: 'lv2', + parentLv1: currentLv1.name, + concept_count: lv2.concept_count, + stock_count: price.stock_count, + avg_change_pct: price.avg_change_pct, + children: lv2.children, + concepts: lv2.concepts, + itemStyle: { + color: getLv1Color(currentLv1.name, index % 3), + borderColor: '#fff', + borderWidth: 2, + }, + }; + }); + } + + if (currentLevel === 'lv3' && currentLv1 && currentLv2) { + // 显示选中 lv2 下的 lv3 或概念 + const lv1Data = hierarchy.find(h => h.name === currentLv1.name); + if (!lv1Data || !lv1Data.children) return []; + + const lv2Data = lv1Data.children.find(h => h.name === currentLv2.name); + if (!lv2Data) return []; + + // 如果有 lv3 子级 + if (lv2Data.children && lv2Data.children.length > 0) { + return lv2Data.children.map((lv3, index) => { + const price = lv3Map[lv3.name] || {}; + return { + name: lv3.name, + value: lv3.concept_count || 3, + id: lv3.id, + level: 'lv3', + parentLv1: currentLv1.name, + parentLv2: currentLv2.name, + concept_count: lv3.concept_count, + stock_count: price.stock_count, + avg_change_pct: price.avg_change_pct, + concepts: lv3.concepts, + itemStyle: { + color: getLv1Color(currentLv1.name, index % 3), + borderColor: '#fff', + borderWidth: 2, + }, + }; + }); + } + + // 如果直接是概念列表 + if (lv2Data.concepts && lv2Data.concepts.length > 0) { + return lv2Data.concepts.map((concept, index) => ({ + name: concept, + value: 1, + level: 'concept', + parentLv1: currentLv1.name, + parentLv2: currentLv2.name, + itemStyle: { + color: getLv1Color(currentLv1.name, index % 3), + borderColor: '#fff', + borderWidth: 1, + }, + })); + } + + return []; + } + + return []; + }, [hierarchy, priceData, currentLevel, currentLv1, currentLv2]); + // ECharts 配置 const chartOption = useMemo(() => { - const treeData = transformToEChartsData(hierarchy, priceData, 1); - if (!treeData) return null; - - const isRadial = layout === 'radial'; + if (!currentData || currentData.length === 0) return null; return { tooltip: { trigger: 'item', - triggerOn: 'mousemove', formatter: (params) => { - const data = params.data?.data || {}; - let content = `