1378 lines
53 KiB
JavaScript
1378 lines
53 KiB
JavaScript
/**
|
||
* 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;
|