Files
vf_react/src/views/Concept/components/ForceGraphView.js
2025-12-05 19:40:11 +08:00

1378 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* TreemapView - 概念层级矩形树图(玻璃态深空风格)
*
* 设计理念:
* - 半透明玻璃态数据终端,漂浮在深空中
* - 极光背景 + 毛玻璃卡片
* - 光影深度、弥散背景光、极致圆角
*
* 特性:
* 1. 默认显示3层一级 → 二级 → 三级),面积按概念数量
* 2. 智能文字颜色:浅色背景用深色字,深色背景用浅色字
* 3. 点击矩形钻取进入,支持返回
* 4. 涨红跌绿颜色映射
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import ReactECharts from 'echarts-for-react';
import {
Box,
VStack,
HStack,
Text,
Spinner,
Center,
Icon,
Flex,
Button,
IconButton,
Tooltip,
Badge,
useBreakpointValue,
} from '@chakra-ui/react';
import { keyframes } from '@emotion/react';
import {
FaLayerGroup,
FaSync,
FaExpand,
FaCompress,
FaHome,
FaArrowUp,
FaArrowDown,
FaCircle,
FaTh,
FaChevronRight,
FaArrowLeft,
} from 'react-icons/fa';
import { logger } from '../../../utils/logger';
// 极光动画 - 黑金色主题
const auroraAnimation = keyframes`
0%, 100% {
background-position: 0% 50%;
filter: hue-rotate(0deg);
}
25% {
background-position: 50% 100%;
filter: hue-rotate(10deg);
}
50% {
background-position: 100% 50%;
filter: hue-rotate(0deg);
}
75% {
background-position: 50% 0%;
filter: hue-rotate(-10deg);
}
`;
// 光晕脉冲动画
const glowPulse = keyframes`
0%, 100% { opacity: 0.3; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.05); }
`;
// 一级分类颜色映射(基础色 - 半透明玻璃态)
const LV1_COLORS = {
'人工智能': '#8B5CF6',
'半导体': '#3B82F6',
'机器人': '#10B981',
'消费电子': '#F59E0B',
'智能驾驶与汽车': '#EF4444',
'新能源与电力': '#06B6D4',
'空天经济': '#6366F1',
'国防军工': '#EC4899',
'政策与主题': '#14B8A6',
'周期与材料': '#F97316',
'大消费': '#A855F7',
'数字经济与金融科技': '#22D3EE',
'全球宏观与贸易': '#84CC16',
'医药健康': '#E879F9',
'前沿科技': '#38BDF8',
};
// 根据涨跌幅获取颜色(涨红跌绿 - 玻璃态半透明)
const getChangeColor = (value, baseColor = '#64748B') => {
if (value === null || value === undefined) return baseColor;
// 涨 - 红色系(调整透明度使其更柔和)
if (value > 7) return 'rgba(220, 38, 38, 0.85)'; // 深红
if (value > 5) return 'rgba(239, 68, 68, 0.8)';
if (value > 3) return 'rgba(248, 113, 113, 0.75)';
if (value > 1) return 'rgba(252, 165, 165, 0.7)';
if (value > 0) return 'rgba(254, 202, 202, 0.65)';
// 跌 - 绿色系
if (value < -7) return 'rgba(21, 128, 61, 0.85)'; // 深绿
if (value < -5) return 'rgba(22, 163, 74, 0.8)';
if (value < -3) return 'rgba(34, 197, 94, 0.75)';
if (value < -1) return 'rgba(74, 222, 128, 0.7)';
if (value < 0) return 'rgba(134, 239, 172, 0.65)';
return baseColor;
};
// 判断颜色是否为浅色(用于决定文字颜色)
const isLightColor = (color) => {
if (!color) return false;
// 处理 rgba 格式
const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgbaMatch) {
const [, r, g, b] = rgbaMatch.map(Number);
// 使用相对亮度公式
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.55;
}
// 处理 hex 格式
const hex = color.replace('#', '');
if (hex.length === 6) {
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.55;
}
return false;
};
// 获取文字颜色(根据背景色自动适配)
const getTextColor = (bgColor, isTitle = false) => {
if (isLightColor(bgColor)) {
return isTitle ? '#1E293B' : '#334155';
}
return isTitle ? '#FFFFFF' : '#E2E8F0';
};
// 获取文字阴影(根据背景色自动适配)
const getTextShadow = (bgColor) => {
if (isLightColor(bgColor)) {
return 'rgba(255,255,255,0.8)';
}
return 'rgba(0,0,0,0.8)';
};
// 从 API 返回的名称中提取纯名称
const extractPureName = (apiName) => {
if (!apiName) return '';
return apiName.replace(/^\[(一级|二级|三级)\]\s*/, '');
};
// 格式化涨跌幅
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 ForceGraphView = ({
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 [isFullscreen, setIsFullscreen] = useState(false);
// 钻取状态:记录当前查看的路径
const [drillPath, setDrillPath] = useState(null);
const chartRef = useRef();
const containerRef = useRef();
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('TreemapView', '层级结构加载完成', {
totalLv1: data.hierarchy?.length,
totalConcepts: data.total_concepts
});
} catch (err) {
logger.error('TreemapView', '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('TreemapView', '获取层级涨跌幅失败', { 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 });
} catch (err) {
logger.warn('TreemapView', '获取层级涨跌幅失败', { error: err.message });
} finally {
setPriceLoading(false);
}
}, [apiBaseUrl, selectedDate]);
useEffect(() => {
fetchHierarchy();
}, [fetchHierarchy]);
useEffect(() => {
if (hierarchy.length > 0) {
fetchHierarchyPrice();
}
}, [hierarchy, fetchHierarchyPrice]);
// 递归计算概念数量
const getConceptCount = useCallback((node) => {
if (!node) return 1;
// 如果是叶子节点概念返回1
if (node.concepts && !node.children) {
return node.concepts.length || 1;
}
// 如果有直接概念
let count = node.concepts?.length || 0;
// 递归计算子节点
if (node.children) {
node.children.forEach(child => {
count += getConceptCount(child);
});
}
return count || 1;
}, []);
// 根据钻取路径构建 Treemap 数据(使用概念数量作为面积)
const treemapData = useMemo(() => {
const { lv1Map, lv2Map, lv3Map, leafMap } = priceData;
// 根视图:显示所有一级,每个一级下显示二级和三级(不显示概念)
if (!drillPath) {
return hierarchy.map((lv1) => {
const lv1BaseColor = LV1_COLORS[lv1.name] || '#8B5CF6';
const lv1Price = lv1Map[lv1.name] || {};
const bgColor = getChangeColor(lv1Price.avg_change_pct, lv1BaseColor);
const lv1Node = {
name: lv1.name,
value: lv1.concept_count || getConceptCount(lv1),
itemStyle: {
color: bgColor,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 2,
borderRadius: 12,
},
data: {
level: 'lv1',
changePct: lv1Price.avg_change_pct,
stockCount: lv1Price.stock_count,
conceptCount: lv1.concept_count,
baseColor: lv1BaseColor,
bgColor: bgColor,
},
children: [],
};
// 二级
if (lv1.children) {
lv1.children.forEach((lv2) => {
const lv2Price = lv2Map[lv2.name] || {};
const lv2BgColor = getChangeColor(lv2Price.avg_change_pct, lv1BaseColor);
const lv2ConceptCount = lv2.concept_count || getConceptCount(lv2);
const lv2Node = {
name: lv2.name,
value: lv2ConceptCount,
itemStyle: {
color: lv2BgColor,
borderColor: 'rgba(255, 255, 255, 0.08)',
borderWidth: 1,
borderRadius: 8,
},
data: {
level: 'lv2',
parentLv1: lv1.name,
changePct: lv2Price.avg_change_pct,
stockCount: lv2Price.stock_count,
conceptCount: lv2ConceptCount,
baseColor: lv1BaseColor,
bgColor: lv2BgColor,
},
children: [],
};
// 三级(不显示概念)
if (lv2.children) {
lv2.children.forEach((lv3) => {
const lv3Price = lv3Map[lv3.name] || {};
const lv3BgColor = getChangeColor(lv3Price.avg_change_pct, lv1BaseColor);
const lv3ConceptCount = lv3.concepts?.length || 1;
lv2Node.children.push({
name: lv3.name,
value: lv3ConceptCount,
itemStyle: {
color: lv3BgColor,
borderColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
borderRadius: 6,
},
data: {
level: 'lv3',
parentLv1: lv1.name,
parentLv2: lv2.name,
changePct: lv3Price.avg_change_pct,
stockCount: lv3Price.stock_count,
conceptCount: lv3ConceptCount,
baseColor: lv1BaseColor,
bgColor: lv3BgColor,
hasChildren: lv3.concepts && lv3.concepts.length > 0,
},
});
});
}
lv1Node.children.push(lv2Node);
});
}
return lv1Node;
});
}
// 钻取到某个一级分类
if (drillPath.lv1 && !drillPath.lv2) {
const lv1 = hierarchy.find(h => h.name === drillPath.lv1);
if (!lv1) return [];
const lv1BaseColor = LV1_COLORS[lv1.name] || '#8B5CF6';
return (lv1.children || []).map((lv2) => {
const lv2Price = lv2Map[lv2.name] || {};
const lv2BgColor = getChangeColor(lv2Price.avg_change_pct, lv1BaseColor);
const lv2ConceptCount = lv2.concept_count || getConceptCount(lv2);
const lv2Node = {
name: lv2.name,
value: lv2ConceptCount,
itemStyle: {
color: lv2BgColor,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 2,
borderRadius: 12,
},
data: {
level: 'lv2',
parentLv1: lv1.name,
changePct: lv2Price.avg_change_pct,
stockCount: lv2Price.stock_count,
conceptCount: lv2ConceptCount,
baseColor: lv1BaseColor,
bgColor: lv2BgColor,
},
children: [],
};
// 三级
if (lv2.children) {
lv2.children.forEach((lv3) => {
const lv3Price = lv3Map[lv3.name] || {};
const lv3BgColor = getChangeColor(lv3Price.avg_change_pct, lv1BaseColor);
const lv3ConceptCount = lv3.concepts?.length || 1;
const lv3Node = {
name: lv3.name,
value: lv3ConceptCount,
itemStyle: {
color: lv3BgColor,
borderColor: 'rgba(255, 255, 255, 0.08)',
borderWidth: 1,
borderRadius: 8,
},
data: {
level: 'lv3',
parentLv1: lv1.name,
parentLv2: lv2.name,
changePct: lv3Price.avg_change_pct,
stockCount: lv3Price.stock_count,
conceptCount: lv3ConceptCount,
baseColor: lv1BaseColor,
bgColor: lv3BgColor,
},
children: [],
};
// 概念
if (lv3.concepts) {
lv3.concepts.forEach((conceptName) => {
const conceptPrice = leafMap[conceptName] || {};
const conceptBgColor = getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor);
lv3Node.children.push({
name: conceptName,
value: 1,
itemStyle: {
color: conceptBgColor,
borderColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
borderRadius: 6,
},
data: {
level: 'concept',
parentLv1: lv1.name,
parentLv2: lv2.name,
parentLv3: lv3.name,
changePct: conceptPrice.avg_change_pct,
stockCount: conceptPrice.stock_count,
baseColor: lv1BaseColor,
bgColor: conceptBgColor,
},
});
});
}
lv2Node.children.push(lv3Node);
});
}
// lv2 直接包含的概念
if (lv2.concepts) {
lv2.concepts.forEach((conceptName) => {
const conceptPrice = leafMap[conceptName] || {};
const conceptBgColor = getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor);
lv2Node.children.push({
name: conceptName,
value: 1,
itemStyle: {
color: conceptBgColor,
borderColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
borderRadius: 6,
},
data: {
level: 'concept',
parentLv1: lv1.name,
parentLv2: lv2.name,
changePct: conceptPrice.avg_change_pct,
stockCount: conceptPrice.stock_count,
baseColor: lv1BaseColor,
bgColor: conceptBgColor,
},
});
});
}
return lv2Node;
});
}
// 钻取到某个二级分类
if (drillPath.lv1 && drillPath.lv2 && !drillPath.lv3) {
const lv1 = hierarchy.find(h => h.name === drillPath.lv1);
if (!lv1) return [];
const lv2 = lv1.children?.find(c => c.name === drillPath.lv2);
if (!lv2) return [];
const lv1BaseColor = LV1_COLORS[lv1.name] || '#8B5CF6';
const result = [];
// 三级
if (lv2.children) {
lv2.children.forEach((lv3) => {
const lv3Price = lv3Map[lv3.name] || {};
const lv3BgColor = getChangeColor(lv3Price.avg_change_pct, lv1BaseColor);
const lv3ConceptCount = lv3.concepts?.length || 1;
const lv3Node = {
name: lv3.name,
value: lv3ConceptCount,
itemStyle: {
color: lv3BgColor,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 2,
borderRadius: 12,
},
data: {
level: 'lv3',
parentLv1: lv1.name,
parentLv2: lv2.name,
changePct: lv3Price.avg_change_pct,
stockCount: lv3Price.stock_count,
conceptCount: lv3ConceptCount,
baseColor: lv1BaseColor,
bgColor: lv3BgColor,
},
children: [],
};
// 概念
if (lv3.concepts) {
lv3.concepts.forEach((conceptName) => {
const conceptPrice = leafMap[conceptName] || {};
const conceptBgColor = getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor);
lv3Node.children.push({
name: conceptName,
value: 1,
itemStyle: {
color: conceptBgColor,
borderColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
borderRadius: 8,
},
data: {
level: 'concept',
parentLv1: lv1.name,
parentLv2: lv2.name,
parentLv3: lv3.name,
changePct: conceptPrice.avg_change_pct,
stockCount: conceptPrice.stock_count,
baseColor: lv1BaseColor,
bgColor: conceptBgColor,
},
});
});
}
result.push(lv3Node);
});
}
// lv2 直接包含的概念
if (lv2.concepts) {
lv2.concepts.forEach((conceptName) => {
const conceptPrice = leafMap[conceptName] || {};
const conceptBgColor = getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor);
result.push({
name: conceptName,
value: 1,
itemStyle: {
color: conceptBgColor,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 2,
borderRadius: 12,
},
data: {
level: 'concept',
parentLv1: lv1.name,
parentLv2: lv2.name,
changePct: conceptPrice.avg_change_pct,
stockCount: conceptPrice.stock_count,
baseColor: lv1BaseColor,
bgColor: conceptBgColor,
},
});
});
}
return result;
}
// 钻取到某个三级分类
if (drillPath.lv1 && drillPath.lv2 && drillPath.lv3) {
const lv1 = hierarchy.find(h => h.name === drillPath.lv1);
if (!lv1) return [];
const lv2 = lv1.children?.find(c => c.name === drillPath.lv2);
if (!lv2) return [];
const lv3 = lv2.children?.find(c => c.name === drillPath.lv3);
if (!lv3) return [];
const lv1BaseColor = LV1_COLORS[lv1.name] || '#8B5CF6';
return (lv3.concepts || []).map((conceptName) => {
const conceptPrice = leafMap[conceptName] || {};
const conceptBgColor = getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor);
return {
name: conceptName,
value: 1,
itemStyle: {
color: conceptBgColor,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 2,
borderRadius: 12,
},
data: {
level: 'concept',
parentLv1: lv1.name,
parentLv2: lv2.name,
parentLv3: lv3.name,
changePct: conceptPrice.avg_change_pct,
stockCount: conceptPrice.stock_count,
baseColor: lv1BaseColor,
bgColor: conceptBgColor,
},
};
});
}
return [];
}, [hierarchy, priceData, drillPath, getConceptCount]);
// ECharts 配置 - 玻璃态深空风格
const chartOption = useMemo(() => {
// 玻璃态层级配置
const levels = [
{
// 根节点配置
itemStyle: {
borderColor: 'transparent',
borderWidth: 0,
gapWidth: 4,
},
},
{
// 第一层 - 主要分类
itemStyle: {
borderColor: 'rgba(255, 255, 255, 0.15)',
borderWidth: 3,
gapWidth: 4,
borderRadius: 16,
shadowBlur: 20,
shadowColor: 'rgba(139, 92, 246, 0.3)',
},
upperLabel: {
show: true,
height: 32,
fontSize: 14,
fontWeight: 'bold',
padding: [0, 8],
formatter: (params) => {
const data = params.data?.data || {};
const changePct = data.changePct;
const changeStr = changePct !== undefined && changePct !== null
? ` ${formatChangePercent(changePct)}`
: '';
return `${params.name}${changeStr}`;
},
rich: {
name: {
fontSize: 14,
fontWeight: 'bold',
}
}
},
color: (params) => {
const bgColor = params.data?.data?.bgColor;
return getTextColor(bgColor, true);
},
},
{
// 第二层
itemStyle: {
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 2,
gapWidth: 3,
borderRadius: 12,
shadowBlur: 10,
shadowColor: 'rgba(139, 92, 246, 0.2)',
},
upperLabel: {
show: true,
height: 26,
fontSize: 12,
fontWeight: 'bold',
padding: [0, 6],
formatter: (params) => {
const data = params.data?.data || {};
const changePct = data.changePct;
const changeStr = changePct !== undefined && changePct !== null
? ` ${formatChangePercent(changePct)}`
: '';
return `${params.name}${changeStr}`;
},
},
},
{
// 第三层
itemStyle: {
borderColor: 'rgba(255, 255, 255, 0.08)',
borderWidth: 1,
gapWidth: 2,
borderRadius: 8,
},
label: {
show: true,
position: 'insideTopLeft',
fontSize: 11,
padding: [4, 6],
formatter: (params) => {
const data = params.data?.data || {};
const bgColor = data.bgColor;
const changePct = data.changePct;
if (changePct !== undefined && changePct !== null) {
return `{name|${params.name}}\n{change|${formatChangePercent(changePct)}}`;
}
return params.name;
},
rich: {
name: {
fontSize: 11,
fontWeight: 'bold',
lineHeight: 16,
},
change: {
fontSize: 10,
lineHeight: 14,
}
}
},
},
{
// 第四层(概念层)
itemStyle: {
borderColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
gapWidth: 2,
borderRadius: 6,
},
label: {
show: true,
position: 'insideTopLeft',
fontSize: 10,
padding: [3, 5],
formatter: (params) => {
const data = params.data?.data || {};
const changePct = data.changePct;
if (changePct !== undefined && changePct !== null) {
return `{name|${params.name}}\n{change|${formatChangePercent(changePct)}}`;
}
return params.name;
},
rich: {
name: {
fontSize: 10,
fontWeight: 'bold',
lineHeight: 14,
},
change: {
fontSize: 9,
lineHeight: 12,
}
}
},
},
];
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderColor: 'rgba(139, 92, 246, 0.4)',
borderWidth: 1,
borderRadius: 16,
padding: [14, 18],
extraCssText: 'backdrop-filter: blur(20px); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);',
textStyle: {
color: '#E2E8F0',
fontSize: 13,
},
formatter: (params) => {
const data = params.data?.data || {};
const levelMap = {
'lv1': '一级分类',
'lv2': '二级分类',
'lv3': '三级分类',
'concept': '概念',
};
const changePct = data.changePct;
const changeColor = changePct > 0 ? '#F87171' : changePct < 0 ? '#4ADE80' : '#94A3B8';
const changeIcon = changePct > 0 ? '▲' : changePct < 0 ? '▼' : '●';
return `
<div style="min-width: 200px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
<span style="
background: linear-gradient(135deg, ${data.baseColor || '#8B5CF6'}, ${data.baseColor || '#8B5CF6'}88);
color: white;
padding: 3px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
box-shadow: 0 2px 8px ${data.baseColor || '#8B5CF6'}44;
">${levelMap[data.level] || '分类'}</span>
</div>
<div style="font-size: 17px; font-weight: bold; margin-bottom: 12px; color: #FFFFFF; text-shadow: 0 2px 4px rgba(0,0,0,0.3);">
${params.name}
</div>
${changePct !== undefined && changePct !== null ? `
<div style="
display: inline-flex;
align-items: center;
gap: 8px;
background: ${changePct > 0 ? 'rgba(248, 113, 113, 0.15)' : changePct < 0 ? 'rgba(74, 222, 128, 0.15)' : 'rgba(148, 163, 184, 0.15)'};
padding: 8px 14px;
border-radius: 12px;
margin-bottom: 12px;
border: 1px solid ${changePct > 0 ? 'rgba(248, 113, 113, 0.3)' : changePct < 0 ? 'rgba(74, 222, 128, 0.3)' : 'rgba(148, 163, 184, 0.3)'};
">
<span style="color: ${changeColor}; font-size: 14px;">${changeIcon}</span>
<span style="color: ${changeColor}; font-weight: bold; font-size: 20px; font-family: 'SF Mono', monospace;">
${formatChangePercent(changePct)}
</span>
</div>
` : ''}
<div style="color: #94A3B8; font-size: 12px; display: flex; gap: 12px;">
${data.stockCount ? `<span>📊 ${data.stockCount} 只股票</span>` : ''}
${data.conceptCount ? `<span>📁 ${data.conceptCount} 个概念</span>` : ''}
</div>
${data.level === 'concept' ? `
<div style="color: #A78BFA; font-size: 11px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1);">
🔗 点击查看概念详情
</div>
` : data.level !== 'concept' ? `
<div style="color: #64748B; font-size: 11px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1);">
📂 点击进入查看子分类
</div>
` : ''}
</div>
`;
},
},
series: [
{
type: 'treemap',
data: treemapData,
left: 8,
top: 60,
right: 8,
bottom: 50,
roam: false,
nodeClick: false,
breadcrumb: {
show: false,
},
levels: levels,
label: {
show: true,
formatter: '{b}',
},
// 动态设置文字颜色
labelLayout: (params) => {
return {};
},
itemStyle: {
borderRadius: 8,
},
emphasis: {
itemStyle: {
shadowBlur: 30,
shadowColor: 'rgba(139, 92, 246, 0.5)',
borderColor: 'rgba(255, 255, 255, 0.3)',
},
},
animation: true,
animationDuration: 600,
animationEasing: 'cubicOut',
},
],
// 使用 visualMap 来控制文字颜色
visualMap: {
show: false,
type: 'continuous',
min: -10,
max: 10,
dimension: 'value',
inRange: {
// 这里可以添加颜色映射
},
},
};
}, [treemapData]);
// 图表事件
const onChartEvents = useMemo(() => ({
click: (params) => {
const data = params.data?.data || {};
if (data.level === 'concept') {
// 跳转到概念详情页
const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(params.name)}.html`;
window.open(htmlPath, '_blank');
return;
}
// 钻取进入
if (data.level === 'lv1') {
setDrillPath({ lv1: params.name });
} else if (data.level === 'lv2') {
setDrillPath({ lv1: data.parentLv1, lv2: params.name });
} else if (data.level === 'lv3') {
setDrillPath({ lv1: data.parentLv1, lv2: data.parentLv2, lv3: params.name });
}
logger.info('TreemapView', '钻取', { level: data.level, name: params.name });
},
}), []);
// 返回上一层
const handleGoBack = useCallback(() => {
if (!drillPath) return;
if (drillPath.lv3) {
setDrillPath({ lv1: drillPath.lv1, lv2: drillPath.lv2 });
} else if (drillPath.lv2) {
setDrillPath({ lv1: drillPath.lv1 });
} else if (drillPath.lv1) {
setDrillPath(null);
}
}, [drillPath]);
// 返回根视图
const handleGoHome = useCallback(() => {
setDrillPath(null);
}, []);
// 刷新数据
const handleRefresh = useCallback(() => {
fetchHierarchyPrice();
}, [fetchHierarchyPrice]);
// 全屏切换
const toggleFullscreen = useCallback(() => {
setIsFullscreen(prev => !prev);
}, []);
// 获取面包屑
const breadcrumbItems = useMemo(() => {
const items = [{ label: '全部分类', path: null }];
if (drillPath?.lv1) {
items.push({ label: drillPath.lv1, path: { lv1: drillPath.lv1 } });
}
if (drillPath?.lv2) {
items.push({ label: drillPath.lv2, path: { lv1: drillPath.lv1, lv2: drillPath.lv2 } });
}
if (drillPath?.lv3) {
items.push({ label: drillPath.lv3, path: { lv1: drillPath.lv1, lv2: drillPath.lv2, lv3: drillPath.lv3 } });
}
return items;
}, [drillPath]);
// 获取容器高度
const containerHeight = useMemo(() => {
if (isFullscreen) return '100vh';
return isMobile ? '500px' : '700px';
}, [isFullscreen, isMobile]);
if (loading) {
return (
<Center
h="500px"
bg="rgba(15, 23, 42, 0.6)"
borderRadius="3xl"
backdropFilter="blur(20px)"
border="1px solid"
borderColor="whiteAlpha.100"
>
<VStack spacing={4}>
<Box
position="relative"
>
<Box
position="absolute"
inset={-4}
bg="purple.500"
borderRadius="full"
filter="blur(20px)"
opacity={0.4}
animation={`${glowPulse} 2s ease-in-out infinite`}
/>
<Spinner size="xl" color="purple.400" thickness="4px" />
</Box>
<Text color="gray.400" fontSize="sm">正在构建矩形树图...</Text>
</VStack>
</Center>
);
}
if (error) {
return (
<Center
h="500px"
bg="rgba(15, 23, 42, 0.6)"
borderRadius="3xl"
backdropFilter="blur(20px)"
border="1px solid"
borderColor="whiteAlpha.100"
>
<VStack spacing={4}>
<Icon as={FaLayerGroup} boxSize={16} color="gray.600" />
<Text color="gray.400">加载失败{error}</Text>
<Button
colorScheme="purple"
size="sm"
onClick={fetchHierarchy}
borderRadius="full"
px={6}
>
重试
</Button>
</VStack>
</Center>
);
}
return (
<Box
ref={containerRef}
position={isFullscreen ? 'fixed' : 'relative'}
top={isFullscreen ? 0 : 'auto'}
left={isFullscreen ? 0 : 'auto'}
right={isFullscreen ? 0 : 'auto'}
bottom={isFullscreen ? 0 : 'auto'}
zIndex={isFullscreen ? 1000 : 'auto'}
borderRadius={isFullscreen ? '0' : '3xl'}
overflow="hidden"
border={isFullscreen ? 'none' : '1px solid'}
borderColor="whiteAlpha.100"
h={containerHeight}
bg="transparent"
>
{/* 极光背景层 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="linear-gradient(135deg, #0F172A 0%, #1E1B4B 25%, #312E81 50%, #1E1B4B 75%, #0F172A 100%)"
backgroundSize="400% 400%"
animation={`${auroraAnimation} 15s ease infinite`}
/>
{/* 弥散光晕层 */}
<Box
position="absolute"
top="20%"
left="10%"
w="300px"
h="300px"
bg="radial-gradient(circle, rgba(139, 92, 246, 0.3) 0%, transparent 70%)"
filter="blur(60px)"
pointerEvents="none"
animation={`${glowPulse} 4s ease-in-out infinite`}
/>
<Box
position="absolute"
bottom="20%"
right="15%"
w="250px"
h="250px"
bg="radial-gradient(circle, rgba(59, 130, 246, 0.25) 0%, transparent 70%)"
filter="blur(50px)"
pointerEvents="none"
animation={`${glowPulse} 5s ease-in-out infinite 1s`}
/>
<Box
position="absolute"
top="50%"
right="30%"
w="200px"
h="200px"
bg="radial-gradient(circle, rgba(236, 72, 153, 0.2) 0%, transparent 70%)"
filter="blur(40px)"
pointerEvents="none"
animation={`${glowPulse} 6s ease-in-out infinite 2s`}
/>
{/* 顶部工具栏 - 毛玻璃风格 */}
<Flex
position="absolute"
top={isFullscreen ? '70px' : 4}
left={4}
right={4}
justify="space-between"
align="flex-start"
zIndex={10}
pointerEvents="none"
>
{/* 左侧标题和面包屑 */}
<VStack align="start" spacing={2} pointerEvents="auto">
<HStack spacing={3}>
{/* 返回按钮 */}
{drillPath && (
<Tooltip label="返回上一层" placement="bottom">
<IconButton
size="sm"
icon={<FaArrowLeft />}
onClick={handleGoBack}
bg="rgba(255, 255, 255, 0.1)"
backdropFilter="blur(20px)"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
borderRadius="full"
_hover={{
bg: 'rgba(139, 92, 246, 0.4)',
borderColor: 'purple.400',
transform: 'scale(1.05)',
}}
transition="all 0.2s"
aria-label="返回"
/>
</Tooltip>
)}
<HStack
bg="rgba(255, 255, 255, 0.08)"
backdropFilter="blur(20px)"
px={5}
py={2.5}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.150"
boxShadow="0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1)"
>
<Icon as={FaTh} color="purple.300" />
<Text color="white" fontWeight="bold" fontSize="sm">
概念矩形树图
</Text>
</HStack>
{/* 面包屑导航 */}
<HStack
bg="rgba(255, 255, 255, 0.06)"
backdropFilter="blur(20px)"
px={4}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.100"
spacing={2}
>
{breadcrumbItems.map((item, index) => (
<HStack key={index} spacing={2}>
{index > 0 && (
<Icon as={FaChevronRight} color="whiteAlpha.400" boxSize={3} />
)}
<Text
color={index === breadcrumbItems.length - 1 ? 'purple.300' : 'whiteAlpha.700'}
fontSize="sm"
fontWeight={index === breadcrumbItems.length - 1 ? 'bold' : 'normal'}
cursor={index < breadcrumbItems.length - 1 ? 'pointer' : 'default'}
_hover={index < breadcrumbItems.length - 1 ? { color: 'white' } : {}}
transition="color 0.2s"
onClick={() => {
if (index < breadcrumbItems.length - 1) {
setDrillPath(item.path);
}
}}
>
{item.label}
</Text>
</HStack>
))}
</HStack>
</HStack>
</VStack>
{/* 右侧控制按钮 */}
<HStack spacing={2} pointerEvents="auto">
{priceLoading && <Spinner size="sm" color="purple.300" />}
{drillPath && (
<Tooltip label="返回全部" placement="left">
<IconButton
size="sm"
icon={<FaHome />}
onClick={handleGoHome}
bg="rgba(255, 255, 255, 0.1)"
backdropFilter="blur(20px)"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
borderRadius="full"
_hover={{
bg: 'rgba(139, 92, 246, 0.4)',
borderColor: 'purple.400',
transform: 'scale(1.05)',
}}
transition="all 0.2s"
aria-label="返回全部"
/>
</Tooltip>
)}
<Tooltip label="刷新数据" placement="left">
<IconButton
size="sm"
icon={<FaSync />}
onClick={handleRefresh}
isLoading={priceLoading}
bg="rgba(255, 255, 255, 0.1)"
backdropFilter="blur(20px)"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
borderRadius="full"
_hover={{
bg: 'rgba(255, 255, 255, 0.2)',
borderColor: 'whiteAlpha.300',
transform: 'scale(1.05)',
}}
transition="all 0.2s"
aria-label="刷新"
/>
</Tooltip>
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'} placement="left">
<IconButton
size="sm"
icon={isFullscreen ? <FaCompress /> : <FaExpand />}
onClick={toggleFullscreen}
bg="rgba(255, 255, 255, 0.1)"
backdropFilter="blur(20px)"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
borderRadius="full"
_hover={{
bg: 'rgba(255, 255, 255, 0.2)',
borderColor: 'whiteAlpha.300',
transform: 'scale(1.05)',
}}
transition="all 0.2s"
aria-label={isFullscreen ? '退出全屏' : '全屏'}
/>
</Tooltip>
</HStack>
</Flex>
{/* 底部图例 - 毛玻璃风格 */}
<Flex
position="absolute"
bottom={4}
left={4}
zIndex={10}
gap={2}
flexWrap="wrap"
pointerEvents="none"
>
<HStack
bg="rgba(255, 255, 255, 0.08)"
backdropFilter="blur(20px)"
px={4}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.100"
spacing={3}
boxShadow="0 4px 16px rgba(0, 0, 0, 0.2)"
>
<HStack spacing={2}>
<Box
w={3}
h={3}
borderRadius="full"
bg="linear-gradient(135deg, #EF4444, #DC2626)"
boxShadow="0 0 8px rgba(239, 68, 68, 0.5)"
/>
<Text color="whiteAlpha.800" fontSize="xs"></Text>
</HStack>
<Box w="1px" h={4} bg="whiteAlpha.200" />
<HStack spacing={2}>
<Box
w={3}
h={3}
borderRadius="full"
bg="linear-gradient(135deg, #22C55E, #16A34A)"
boxShadow="0 0 8px rgba(34, 197, 94, 0.5)"
/>
<Text color="whiteAlpha.800" fontSize="xs"></Text>
</HStack>
</HStack>
</Flex>
{/* 操作提示 */}
<Box
position="absolute"
bottom={4}
right={4}
zIndex={10}
pointerEvents="none"
>
<HStack
bg="rgba(255, 255, 255, 0.06)"
backdropFilter="blur(20px)"
px={4}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.100"
>
<Text color="whiteAlpha.500" fontSize="xs">
点击分类进入 · 点击概念查看详情
</Text>
</HStack>
</Box>
{/* ECharts 图表 */}
<ReactECharts
ref={chartRef}
option={chartOption}
style={{ height: '100%', width: '100%' }}
onEvents={onChartEvents}
opts={{ renderer: 'canvas' }}
/>
</Box>
);
};
export default ForceGraphView;