/**
* HierarchyView - 概念层级热力图视图
*
* Modern Spatial & Glassmorphism 设计风格
* 特性:
* 1. 毛玻璃卡片 + 极光背景
* 2. 涨红跌绿渐变色
* 3. 支持 lv1 → lv2 → lv3 → leaf 四层钻取
* 4. 集成 /hierarchy/price 接口(含 leaf_concepts)
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Icon,
Spinner,
Center,
Flex,
Button,
useBreakpointValue,
Tooltip,
IconButton,
SimpleGrid,
} from '@chakra-ui/react';
import { keyframes } from '@emotion/react';
import {
FaLayerGroup,
FaExpand,
FaCompress,
FaSync,
FaHome,
FaChevronRight,
FaBrain,
FaMicrochip,
FaRobot,
FaMobileAlt,
FaCar,
FaBolt,
FaRocket,
FaShieldAlt,
FaGlobe,
FaIndustry,
FaShoppingCart,
FaCoins,
FaHeartbeat,
FaAtom,
FaArrowUp,
FaArrowDown,
FaCubes,
FaServer,
FaCode,
FaMagic,
FaEye,
FaPlane,
FaSatellite,
FaBatteryFull,
FaSolarPanel,
FaTags,
FaExternalLinkAlt,
} from 'react-icons/fa';
import { logger } from '../../../utils/logger';
// 一级分类图标映射
const LV1_ICONS = {
'人工智能': FaBrain,
'半导体': FaMicrochip,
'机器人': FaRobot,
'消费电子': FaMobileAlt,
'智能驾驶与汽车': FaCar,
'新能源与电力': FaBolt,
'空天经济': FaRocket,
'国防军工': FaShieldAlt,
'政策与主题': FaGlobe,
'周期与材料': FaIndustry,
'大消费': FaShoppingCart,
'数字经济与金融科技': FaCoins,
'全球宏观与贸易': FaGlobe,
'医药健康': FaHeartbeat,
'前沿科技': FaAtom,
};
// 二级分类图标映射
const LV2_ICONS = {
'AI基础设施': FaServer,
'AI模型与软件': FaCode,
'AI应用': FaMagic,
'半导体设备': FaCubes,
'半导体材料': FaAtom,
'芯片设计与制造': FaMicrochip,
'先进封装': FaCubes,
'人形机器人整机': FaRobot,
'机器人核心零部件': FaCubes,
'其他类型机器人': FaRobot,
'智能终端': FaMobileAlt,
'XR与空间计算': FaEye,
'华为产业链': FaMobileAlt,
'自动驾驶解决方案': FaCar,
'智能汽车产业链': FaCar,
'车路协同': FaCar,
'新型电池技术': FaBatteryFull,
'电力设备与电网': FaBolt,
'清洁能源': FaSolarPanel,
'低空经济': FaPlane,
'商业航天': FaSatellite,
'无人作战与信息化': FaShieldAlt,
'海军装备': FaShieldAlt,
'军贸出海': FaGlobe,
};
// 根据涨跌幅获取背景渐变色(涨红跌绿)
const getChangeGradient = (value) => {
if (value === null || value === undefined) {
return 'linear-gradient(135deg, rgba(71, 85, 105, 0.6) 0%, rgba(100, 116, 139, 0.4) 100%)';
}
// 涨 - 红色系
if (value > 7) return 'linear-gradient(135deg, rgba(153, 27, 27, 0.8) 0%, rgba(220, 38, 38, 0.6) 100%)';
if (value > 5) return 'linear-gradient(135deg, rgba(185, 28, 28, 0.75) 0%, rgba(239, 68, 68, 0.55) 100%)';
if (value > 3) return 'linear-gradient(135deg, rgba(220, 38, 38, 0.7) 0%, rgba(248, 113, 113, 0.5) 100%)';
if (value > 1) return 'linear-gradient(135deg, rgba(239, 68, 68, 0.65) 0%, rgba(252, 165, 165, 0.45) 100%)';
if (value > 0) return 'linear-gradient(135deg, rgba(248, 113, 113, 0.6) 0%, rgba(254, 202, 202, 0.4) 100%)';
// 跌 - 绿色系
if (value < -7) return 'linear-gradient(135deg, rgba(20, 83, 45, 0.8) 0%, rgba(22, 101, 52, 0.6) 100%)';
if (value < -5) return 'linear-gradient(135deg, rgba(22, 101, 52, 0.75) 0%, rgba(21, 128, 61, 0.55) 100%)';
if (value < -3) return 'linear-gradient(135deg, rgba(21, 128, 61, 0.7) 0%, rgba(22, 163, 74, 0.5) 100%)';
if (value < -1) return 'linear-gradient(135deg, rgba(22, 163, 74, 0.65) 0%, rgba(74, 222, 128, 0.45) 100%)';
if (value < 0) return 'linear-gradient(135deg, rgba(34, 197, 94, 0.6) 0%, rgba(134, 239, 172, 0.4) 100%)';
// 平盘
return 'linear-gradient(135deg, rgba(71, 85, 105, 0.6) 0%, rgba(100, 116, 139, 0.4) 100%)';
};
// 获取涨跌幅文字颜色
const getChangeTextColor = (value) => {
if (value === null || value === undefined) return 'gray.300';
if (value > 0) return '#FCA5A5';
if (value < 0) return '#86EFAC';
return 'gray.300';
};
// 格式化涨跌幅
const formatChangePercent = (value) => {
if (value === null || value === undefined) return '--';
const formatted = Math.abs(value).toFixed(2);
return value > 0 ? `+${formatted}%` : value < 0 ? `-${formatted}%` : '0.00%';
};
// 获取图标
const getIcon = (name, level) => {
if (level === 'lv1') return LV1_ICONS[name] || FaLayerGroup;
if (level === 'lv2') return LV2_ICONS[name] || FaCubes;
if (level === 'lv3') return FaCubes;
return FaTags;
};
// 从 API 返回的名称中提取纯名称
const extractPureName = (apiName) => {
if (!apiName) return '';
return apiName.replace(/^\[(一级|二级|三级)\]\s*/, '');
};
// 呼吸动画
const breatheKeyframes = keyframes`
0%, 100% { opacity: 0.3; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.05); }
`;
// 浮动动画
const floatKeyframes = keyframes`
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
`;
/**
* 极光背景组件
*/
const AuroraBackground = () => (
{/* 紫色光斑 - 左上 */}
{/* 蓝色光斑 - 右下 */}
{/* 青色光斑 - 中间 */}
);
/**
* 毛玻璃卡片组件
*/
const GlassCard = ({ item, onClick, size = 'normal' }) => {
const hasChange = item.avg_change_pct !== null && item.avg_change_pct !== undefined;
const isPositive = hasChange && item.avg_change_pct > 0;
const isNegative = hasChange && item.avg_change_pct < 0;
const isLargeChange = hasChange && Math.abs(item.avg_change_pct) > 3;
const IconComponent = getIcon(item.name, item.level);
// 根据 size 调整高度
const heightMap = {
large: { base: '150px', md: '170px' },
normal: { base: '120px', md: '140px' },
small: { base: '100px', md: '110px' },
};
// 是否可点击进入下一层
const canDrillDown = item.level !== 'concept' && (item.children?.length > 0 || item.concepts?.length > 0);
const isLeafConcept = item.level === 'concept';
return (
onClick(item)}
transition="all 0.4s cubic-bezier(0.4, 0, 0.2, 1)"
transform="translateZ(0)"
_hover={{
transform: 'translateY(-6px) scale(1.02)',
}}
css={isLargeChange ? { animation: `${floatKeyframes} 3s ease-in-out infinite` } : {}}
>
{/* 毛玻璃主体 */}
{/* 高光效果 */}
{/* 内容 */}
{/* 顶部:图标和名称 */}
{item.name}
{item.concept_count > 0 && (
{item.concept_count} 个概念
)}
{/* 外链图标 */}
{isLeafConcept && (
)}
{/* 底部:涨跌幅 */}
{item.stock_count > 0 && (
{item.stock_count} 只股票
)}
{hasChange && (isPositive || isNegative) && (
)}
{formatChangePercent(item.avg_change_pct)}
{/* 展开标识 */}
{canDrillDown && (
展开
)}
);
};
/**
* 主组件:层级热力图视图
*/
const HierarchyView = ({
apiBaseUrl,
onSelectCategory,
selectedDate,
}) => {
const [hierarchy, setHierarchy] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [priceData, setPriceData] = useState({ lv1Map: {}, lv2Map: {}, lv3Map: {}, leafMap: {} });
const [priceLoading, setPriceLoading] = useState(false);
const [tradeDate, setTradeDate] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
// 钻取状态
const [currentLevel, setCurrentLevel] = useState('lv1');
const [currentLv1, setCurrentLv1] = useState(null);
const [currentLv2, setCurrentLv2] = useState(null);
const [currentLv3, setCurrentLv3] = useState(null);
const [breadcrumbs, setBreadcrumbs] = useState([{ label: '全部分类', level: 'root' }]);
const isMobile = useBreakpointValue({ base: true, md: false });
// 获取层级结构数据
const fetchHierarchy = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`${apiBaseUrl}/hierarchy`);
if (!response.ok) throw new Error('获取层级结构失败');
const data = await response.json();
setHierarchy(data.hierarchy || []);
logger.info('HierarchyView', '层级结构加载完成', {
totalLv1: data.hierarchy?.length,
totalConcepts: data.total_concepts
});
} catch (err) {
logger.error('HierarchyView', 'fetchHierarchy', err);
setError(err.message);
} finally {
setLoading(false);
}
}, [apiBaseUrl]);
// 获取层级涨跌幅数据
const fetchHierarchyPrice = useCallback(async () => {
setPriceLoading(true);
try {
let url = `${apiBaseUrl}/hierarchy/price`;
if (selectedDate) {
const dateStr = selectedDate.toISOString().split('T')[0];
url += `?trade_date=${dateStr}`;
}
const response = await fetch(url);
if (!response.ok) {
logger.warn('HierarchyView', '获取层级涨跌幅失败', { status: response.status });
return;
}
const data = await response.json();
// 构建映射表
const lv1Map = {};
const lv2Map = {};
const lv3Map = {};
const leafMap = {};
(data.lv1_concepts || []).forEach(item => {
const pureName = extractPureName(item.concept_name);
lv1Map[pureName] = item;
});
(data.lv2_concepts || []).forEach(item => {
const pureName = extractPureName(item.concept_name);
lv2Map[pureName] = item;
});
(data.lv3_concepts || []).forEach(item => {
const pureName = extractPureName(item.concept_name);
lv3Map[pureName] = item;
});
// 叶子概念 - 直接用名称
(data.leaf_concepts || []).forEach(item => {
leafMap[item.concept_name] = item;
});
setPriceData({ lv1Map, lv2Map, lv3Map, leafMap });
setTradeDate(data.trade_date);
logger.info('HierarchyView', '层级涨跌幅加载完成', {
lv1Count: Object.keys(lv1Map).length,
lv2Count: Object.keys(lv2Map).length,
lv3Count: Object.keys(lv3Map).length,
leafCount: Object.keys(leafMap).length,
tradeDate: data.trade_date,
});
} catch (err) {
logger.warn('HierarchyView', '获取层级涨跌幅失败', { error: err.message });
} finally {
setPriceLoading(false);
}
}, [apiBaseUrl, selectedDate]);
useEffect(() => {
fetchHierarchy();
}, [fetchHierarchy]);
useEffect(() => {
if (hierarchy.length > 0) {
fetchHierarchyPrice();
}
}, [hierarchy, fetchHierarchyPrice]);
// 根据当前层级获取显示数据
const currentData = useMemo(() => {
const { lv1Map, lv2Map, lv3Map, leafMap } = priceData;
// 第一层:显示所有 lv1
if (currentLevel === 'lv1') {
return hierarchy.map((lv1) => {
const price = lv1Map[lv1.name] || {};
return {
name: lv1.name,
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,
};
});
}
// 第二层:显示选中 lv1 下的 lv2
if (currentLevel === 'lv2' && currentLv1) {
const lv1Data = hierarchy.find(h => h.name === currentLv1.name);
if (!lv1Data || !lv1Data.children) return [];
return lv1Data.children.map((lv2) => {
const price = lv2Map[lv2.name] || {};
return {
name: lv2.name,
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,
};
});
}
// 第三层:显示选中 lv2 下的 lv3
if (currentLevel === 'lv3' && currentLv1 && currentLv2) {
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) => {
const price = lv3Map[lv3.name] || {};
return {
name: lv3.name,
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,
};
});
}
// 如果 lv2 直接包含概念(没有 lv3)
if (lv2Data.concepts && lv2Data.concepts.length > 0) {
return lv2Data.concepts.map((conceptName) => {
const price = leafMap[conceptName] || {};
return {
name: conceptName,
level: 'concept',
parentLv1: currentLv1.name,
parentLv2: currentLv2.name,
stock_count: price.stock_count,
avg_change_pct: price.avg_change_pct,
};
});
}
return [];
}
// 第四层:显示选中 lv3 下的具体概念
if (currentLevel === 'concept' && currentLv1 && currentLv2 && currentLv3) {
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 || !lv2Data.children) return [];
const lv3Data = lv2Data.children.find(h => h.name === currentLv3.name);
if (!lv3Data || !lv3Data.concepts) return [];
return lv3Data.concepts.map((conceptName) => {
const price = leafMap[conceptName] || {};
return {
name: conceptName,
level: 'concept',
parentLv1: currentLv1.name,
parentLv2: currentLv2.name,
parentLv3: currentLv3.name,
stock_count: price.stock_count,
avg_change_pct: price.avg_change_pct,
};
});
}
return [];
}, [hierarchy, priceData, currentLevel, currentLv1, currentLv2, currentLv3]);
// 处理点击事件 - 钻取
const handleBlockClick = useCallback((item) => {
logger.info('HierarchyView', '热力图点击', { level: item.level, name: item.name });
if (item.level === 'lv1' && item.children && item.children.length > 0) {
setCurrentLevel('lv2');
setCurrentLv1(item);
setBreadcrumbs([
{ label: '全部分类', level: 'root' },
{ label: item.name, level: 'lv1', data: item },
]);
} else if (item.level === 'lv2') {
if (item.children && item.children.length > 0) {
setCurrentLevel('lv3');
setCurrentLv2(item);
setBreadcrumbs([
{ label: '全部分类', level: 'root' },
{ label: currentLv1.name, level: 'lv1', data: currentLv1 },
{ label: item.name, level: 'lv2', data: item },
]);
} else if (item.concepts && item.concepts.length > 0) {
setCurrentLevel('lv3');
setCurrentLv2(item);
setBreadcrumbs([
{ label: '全部分类', level: 'root' },
{ label: currentLv1.name, level: 'lv1', data: currentLv1 },
{ label: item.name, level: 'lv2', data: item },
]);
}
} else if (item.level === 'lv3' && item.concepts && item.concepts.length > 0) {
setCurrentLevel('concept');
setCurrentLv3(item);
setBreadcrumbs([
{ label: '全部分类', level: 'root' },
{ label: currentLv1.name, level: 'lv1', data: currentLv1 },
{ label: currentLv2.name, level: 'lv2', data: currentLv2 },
{ label: item.name, level: 'lv3', data: item },
]);
} else if (item.level === 'concept') {
// 跳转到概念详情页
const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(item.name)}.html`;
window.open(htmlPath, '_blank');
}
}, [currentLv1, currentLv2]);
// 面包屑导航
const handleBreadcrumbClick = useCallback((crumb, index) => {
if (crumb.level === 'root') {
setCurrentLevel('lv1');
setCurrentLv1(null);
setCurrentLv2(null);
setCurrentLv3(null);
setBreadcrumbs([{ label: '全部分类', level: 'root' }]);
} else if (crumb.level === 'lv1') {
setCurrentLevel('lv2');
setCurrentLv1(crumb.data);
setCurrentLv2(null);
setCurrentLv3(null);
setBreadcrumbs(breadcrumbs.slice(0, index + 1));
} else if (crumb.level === 'lv2') {
setCurrentLevel('lv3');
setCurrentLv2(crumb.data);
setCurrentLv3(null);
setBreadcrumbs(breadcrumbs.slice(0, index + 1));
}
}, [breadcrumbs]);
// 刷新
const handleRefreshPrice = useCallback(() => {
fetchHierarchyPrice();
}, [fetchHierarchyPrice]);
// 全屏切换
const toggleFullscreen = useCallback(() => {
setIsFullscreen(prev => !prev);
}, []);
// 获取当前层级标题
const getCurrentTitle = () => {
if (currentLevel === 'lv1') return '概念分类热力图';
if (currentLevel === 'lv2' && currentLv1) return currentLv1.name;
if (currentLevel === 'lv3' && currentLv2) return currentLv2.name;
if (currentLevel === 'concept' && currentLv3) return currentLv3.name;
return '概念分类';
};
// 获取当前层级描述
const getLevelDesc = () => {
const levelNames = {
lv1: '一级分类',
lv2: '二级分类',
lv3: '三级分类',
concept: '具体概念',
};
return levelNames[currentLevel] || '';
};
// 计算列数
const getGridColumns = () => {
if (currentLevel === 'concept') {
return { base: 2, md: 3, lg: 4 };
}
return { base: 2, md: 3, lg: 4 };
};
if (loading) {
return (
正在加载概念层级...
);
}
if (error) {
return (
加载失败:{error}
);
}
if (currentData.length === 0) {
return (
暂无层级数据
);
}
return (
{/* 极光背景 - 仅全屏时显示 */}
{isFullscreen && }
{/* 内容层 */}
{/* 面包屑导航 + 工具栏(同一行) */}
{/* 左侧:面包屑导航 */}
{breadcrumbs.map((crumb, index) => (
{index > 0 && (
)}
: undefined}
onClick={() => handleBreadcrumbClick(crumb, index)}
isDisabled={index === breadcrumbs.length - 1}
fontWeight={index === breadcrumbs.length - 1 ? 'bold' : 'medium'}
borderRadius="xl"
_hover={index !== breadcrumbs.length - 1 ? { bg: 'whiteAlpha.100' } : {}}
boxShadow={index === breadcrumbs.length - 1 ? '0 0 20px rgba(139, 92, 246, 0.5)' : 'none'}
>
{crumb.label}
))}
{/* 右侧:工具栏按钮 */}
{priceLoading && (
)}
}
onClick={handleRefreshPrice}
isLoading={priceLoading}
bg="whiteAlpha.100"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
_hover={{ bg: 'whiteAlpha.200' }}
aria-label="刷新涨跌幅"
/>
: }
onClick={toggleFullscreen}
bg="whiteAlpha.100"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
_hover={{ bg: 'whiteAlpha.200' }}
aria-label={isFullscreen ? '退出全屏' : '全屏'}
/>
{/* 图例说明 */}
涨
跌
平/无数据
|
{currentLevel !== 'concept' ? '点击色块查看下级' : '点击查看概念详情'}
{/* 热力图网格 */}
{currentData.map((item) => (
))}
);
};
export default HierarchyView;