diff --git a/src/views/Community/components/HeroPanel.js b/src/views/Community/components/HeroPanel.js index 11828a35..0758544d 100644 --- a/src/views/Community/components/HeroPanel.js +++ b/src/views/Community/components/HeroPanel.js @@ -57,6 +57,7 @@ import ReactMarkdown from 'react-markdown'; import dayjs from 'dayjs'; import KLineChartModal from '@components/StockChart/KLineChartModal'; import { FullCalendarPro } from '@components/Calendar'; +import ThemeCometChart from './ThemeCometChart'; const { TabPane } = Tabs; const { Text: AntText } = Typography; @@ -2708,15 +2709,30 @@ const HeroPanel = () => { - {/* 综合日历区域 - 左侧1/3空白,右侧2/3日历 */} + {/* AI舆情时空决策驾驶舱 - 左侧词频流星图,右侧日历 */} - {/* 左侧留白区域 - 占 1/3 */} + {/* 左侧:词频流星图 - 占 1/2 */} - {/* 预留空间供后续使用 */} + { + // 当用户点击某个主题时,可以展示相关股票 + if (data?.stocks?.length > 0) { + setSelectedSectorInfo({ + name: data.theme.label, + count: data.stocks.length, + stocks: data.stocks.map((s) => ({ + ...s, + _continuousDays: parseInt(s.continuous_days) || 1, + })), + }); + setSectorStocksModalVisible(true); + } + }} + /> - {/* 右侧日历区域 - 占 2/3 */} - + {/* 右侧:综合日历 - 占 1/2 */} + diff --git a/src/views/Community/components/ThemeCometChart.js b/src/views/Community/components/ThemeCometChart.js new file mode 100644 index 00000000..c09d2f53 --- /dev/null +++ b/src/views/Community/components/ThemeCometChart.js @@ -0,0 +1,456 @@ +/** + * ThemeCometChart - 词频流星图 + * 展示核心主题词频的时间演变轨迹,用流星拖尾效果展示趋势 + */ +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { + Box, + Text, + VStack, + HStack, + Spinner, + Center, + Badge, + Tooltip, + useToast, +} from '@chakra-ui/react'; +import ReactECharts from 'echarts-for-react'; +import { RocketOutlined } from '@ant-design/icons'; + +// 核心主题词配置(过滤噪音词,保留有意义的主题) +const CORE_THEMES = [ + { word: '航天', label: '商业航天', color: '#FF6B6B' }, + { word: '机器人', label: '机器人', color: '#4ECDC4' }, + { word: '脑机', label: '脑机接口', color: '#A78BFA' }, + { word: '光刻胶', label: '光刻胶', color: '#F59E0B' }, + { word: '算力', label: 'AI算力', color: '#3B82F6' }, + { word: '电池', label: '固态电池', color: '#10B981' }, + { word: '液冷', label: 'AI液冷', color: '#06B6D4' }, + { word: '军工', label: '军工', color: '#EF4444' }, +]; + +// 噪音词黑名单 +const NOISE_WORDS = [ + '落地', '科技', '智能', '标的', '资产', '传闻', '业绩', '估值', + '商业', '卫星', '国产', '替代', '材料', '概念', '板块', '公司', + '市场', '预期', '政策', '产业', '技术', '研发', '布局', '龙头', +]; + +/** + * 从 public/data/zt 读取多日词频数据 + */ +const fetchMultiDayWordFreq = async (days = 7) => { + try { + // 先获取日期列表 + const datesRes = await fetch('/data/zt/dates.json'); + const datesData = await datesRes.json(); + const recentDates = datesData.dates.slice(0, days); + + // 并行获取每日数据 + const dailyDataPromises = recentDates.map(async (dateInfo) => { + try { + const res = await fetch(`/data/zt/daily/${dateInfo.date}.json`); + if (!res.ok) return null; + const data = await res.json(); + return { + date: dateInfo.date, + formattedDate: dateInfo.formatted_date, + wordFreq: data.word_freq_data || [], + sectorData: data.sector_data || {}, + stocks: data.stocks || [], + totalStocks: dateInfo.count, + }; + } catch { + return null; + } + }); + + const results = await Promise.all(dailyDataPromises); + return results.filter(Boolean).reverse(); // 按时间正序排列 + } catch (error) { + console.error('获取词频数据失败:', error); + return []; + } +}; + +/** + * 处理词频数据,提取核心主题的时间序列 + */ +const processWordFreqData = (dailyData) => { + if (!dailyData || dailyData.length === 0) return { themes: [], dates: [] }; + + const dates = dailyData.map((d) => d.formattedDate.slice(5)); // MM-DD 格式 + const themes = []; + + CORE_THEMES.forEach((theme) => { + const values = dailyData.map((day) => { + // 查找匹配的词频 + const found = day.wordFreq.find((w) => w.name.includes(theme.word)); + return found ? found.value : 0; + }); + + // 归一化到 0-100 + const maxVal = Math.max(...values, 1); + const normalizedValues = values.map((v) => Math.round((v / maxVal) * 100)); + + // 计算趋势(最近两天的变化) + const trend = + normalizedValues.length >= 2 + ? normalizedValues[normalizedValues.length - 1] - + normalizedValues[normalizedValues.length - 2] + : 0; + + themes.push({ + ...theme, + values: normalizedValues, + rawValues: values, + trend, + currentValue: normalizedValues[normalizedValues.length - 1] || 0, + }); + }); + + // 按当前热度排序 + themes.sort((a, b) => b.currentValue - a.currentValue); + + return { themes, dates, dailyData }; +}; + +/** + * 生成 ECharts 配置 + */ +const generateChartOption = (themes, dates, onThemeClick) => { + if (!themes || themes.length === 0) { + return {}; + } + + // 只显示 Top 6 主题 + const topThemes = themes.slice(0, 6); + + const series = []; + + topThemes.forEach((theme, index) => { + // 主线条 - 带渐变的折线 + series.push({ + name: theme.label, + type: 'line', + smooth: true, + symbol: 'none', + lineStyle: { + width: 3, + color: { + type: 'linear', + x: 0, + y: 0, + x2: 1, + y2: 0, + colorStops: [ + { offset: 0, color: `${theme.color}33` }, // 起点透明 + { offset: 0.5, color: `${theme.color}88` }, + { offset: 1, color: theme.color }, // 终点实色 + ], + }, + shadowColor: theme.color, + shadowBlur: 10, + }, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: `${theme.color}40` }, + { offset: 1, color: `${theme.color}05` }, + ], + }, + }, + data: theme.values, + z: 10 + index, + }); + + // 流星头 - 只在最后一个点显示 + const lastValue = theme.values[theme.values.length - 1]; + const effectData = new Array(theme.values.length - 1).fill(null); + effectData.push(lastValue); + + series.push({ + name: `${theme.label}-head`, + type: 'effectScatter', + coordinateSystem: 'cartesian2d', + data: effectData.map((v, i) => (v !== null ? [i, v] : null)).filter(Boolean), + symbolSize: (val) => Math.max(12, val[1] / 5), + showEffectOn: 'render', + rippleEffect: { + brushType: 'stroke', + scale: 3, + period: 4, + }, + itemStyle: { + color: theme.color, + shadowBlur: 20, + shadowColor: theme.color, + }, + label: { + show: true, + formatter: theme.label, + position: 'right', + color: theme.color, + fontSize: 12, + fontWeight: 'bold', + textShadowColor: 'rgba(0,0,0,0.8)', + textShadowBlur: 4, + }, + z: 20 + index, + }); + }); + + return { + backgroundColor: 'transparent', + tooltip: { + trigger: 'axis', + backgroundColor: 'rgba(15, 15, 30, 0.95)', + borderColor: 'rgba(255, 215, 0, 0.3)', + borderWidth: 1, + textStyle: { + color: '#fff', + }, + formatter: (params) => { + if (!params || params.length === 0) return ''; + const dateIndex = params[0].dataIndex; + const date = dates[dateIndex]; + let html = `
${date}
`; + params + .filter((p) => p.seriesName && !p.seriesName.includes('-head')) + .forEach((p) => { + const theme = topThemes.find((t) => t.label === p.seriesName); + if (theme) { + const trend = p.dataIndex > 0 ? theme.values[p.dataIndex] - theme.values[p.dataIndex - 1] : 0; + const trendIcon = trend > 0 ? '🔥' : trend < 0 ? '❄️' : '➡️'; + html += `
+ ${p.seriesName} + ${p.value} ${trendIcon} +
`; + } + }); + return html; + }, + }, + legend: { + show: false, + }, + grid: { + left: '3%', + right: '15%', + top: '10%', + bottom: '12%', + containLabel: true, + }, + xAxis: { + type: 'category', + data: dates, + boundaryGap: false, + axisLine: { + lineStyle: { + color: 'rgba(255, 255, 255, 0.2)', + }, + }, + axisLabel: { + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 11, + }, + splitLine: { + show: false, + }, + }, + yAxis: { + type: 'value', + min: 0, + max: 100, + axisLine: { + show: false, + }, + axisLabel: { + color: 'rgba(255, 255, 255, 0.4)', + fontSize: 10, + formatter: '{value}', + }, + splitLine: { + lineStyle: { + color: 'rgba(255, 255, 255, 0.05)', + }, + }, + }, + series, + }; +}; + +/** + * ThemeCometChart 主组件 + */ +const ThemeCometChart = ({ onThemeSelect }) => { + const [loading, setLoading] = useState(true); + const [data, setData] = useState({ themes: [], dates: [], dailyData: [] }); + const [selectedTheme, setSelectedTheme] = useState(null); + const toast = useToast(); + + // 加载数据 + useEffect(() => { + const loadData = async () => { + setLoading(true); + try { + const dailyData = await fetchMultiDayWordFreq(7); + const processed = processWordFreqData(dailyData); + setData(processed); + } catch (error) { + console.error('加载词频数据失败:', error); + toast({ + title: '加载数据失败', + status: 'error', + duration: 3000, + }); + } finally { + setLoading(false); + } + }; + loadData(); + }, [toast]); + + // 图表配置 + const chartOption = useMemo(() => { + return generateChartOption(data.themes, data.dates, setSelectedTheme); + }, [data]); + + // 点击主题 + const handleThemeClick = useCallback( + (theme) => { + setSelectedTheme(theme); + if (onThemeSelect) { + // 找出该主题相关的股票 + const latestDay = data.dailyData[data.dailyData.length - 1]; + if (latestDay) { + const relatedStocks = latestDay.stocks.filter( + (stock) => + stock.brief?.includes(theme.word) || + stock.core_sectors?.some((s) => s.includes(theme.word)) + ); + onThemeSelect({ + theme, + stocks: relatedStocks, + date: latestDay.formattedDate, + }); + } + } + }, + [data, onThemeSelect] + ); + + // 图表事件 + const onChartEvents = useMemo( + () => ({ + click: (params) => { + if (params.seriesName && !params.seriesName.includes('-head')) { + const theme = data.themes.find((t) => t.label === params.seriesName); + if (theme) { + handleThemeClick(theme); + } + } + }, + }), + [data.themes, handleThemeClick] + ); + + if (loading) { + return ( +
+ + + 加载词频数据... + +
+ ); + } + + if (!data.themes || data.themes.length === 0) { + return ( +
+ 暂无词频数据 +
+ ); + } + + return ( + + {/* 标题栏 */} + + + + + + + 题材热力追踪 + + + 核心主题词频演变 · 最近7个交易日 + + + + + {/* 图表区域 */} + + + + + {/* 底部图例 */} + + {data.themes.slice(0, 6).map((theme) => ( + 0 ? '+' : ''}${theme.trend}`} + placement="top" + > + handleThemeClick(theme)} + _hover={{ + bg: `${theme.color}40`, + transform: 'scale(1.05)', + }} + transition="all 0.2s" + > + {theme.label} + {theme.trend > 5 && ' 🔥'} + {theme.trend < -5 && ' ❄️'} + + + ))} + + + ); +}; + +export default ThemeCometChart;