community增加事件详情
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* ThemeCometChart - 词频流星图
|
* ThemeCometChart - 题材流星图(散点图版本)
|
||||||
* 展示核心主题词频的时间演变轨迹,用流星拖尾效果展示趋势
|
* X轴:辨识度(最高板高度,即板块内最大连板数)
|
||||||
|
* Y轴:板块热度(涨停家数)
|
||||||
|
* 散点大小/颜色:表示不同状态(主升、退潮、潜伏、抱团)
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -10,237 +12,212 @@ import {
|
|||||||
HStack,
|
HStack,
|
||||||
Spinner,
|
Spinner,
|
||||||
Center,
|
Center,
|
||||||
Badge,
|
|
||||||
Tooltip,
|
|
||||||
useToast,
|
useToast,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from 'echarts-for-react';
|
||||||
import { RocketOutlined } from '@ant-design/icons';
|
import { RocketOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
// 核心主题词配置(过滤噪音词,保留有意义的主题)
|
// 板块状态分类
|
||||||
const CORE_THEMES = [
|
const SECTOR_STATUS = {
|
||||||
{ word: '航天', label: '商业航天', color: '#FF6B6B' },
|
RISING: { name: '主升', color: '#FF4D4F', icon: '🔥' }, // 高辨识度+高热度+上升趋势
|
||||||
{ word: '机器人', label: '机器人', color: '#4ECDC4' },
|
DECLINING: { name: '退潮', color: '#52C41A', icon: '❄️' }, // 高辨识度+热度下降
|
||||||
{ word: '脑机', label: '脑机接口', color: '#A78BFA' },
|
LURKING: { name: '潜伏', color: '#1890FF', icon: '●' }, // 低辨识度+热度上升
|
||||||
{ word: '光刻胶', label: '光刻胶', color: '#F59E0B' },
|
CLUSTERING: { name: '抱团', color: '#722ED1', icon: '●' }, // 中等辨识度+稳定热度
|
||||||
{ word: '算力', label: 'AI算力', color: '#3B82F6' },
|
|
||||||
{ word: '电池', label: '固态电池', color: '#10B981' },
|
|
||||||
{ word: '液冷', label: 'AI液冷', color: '#06B6D4' },
|
|
||||||
{ word: '军工', label: '军工', color: '#EF4444' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 噪音词黑名单
|
|
||||||
const NOISE_WORDS = [
|
|
||||||
'落地', '科技', '智能', '标的', '资产', '传闻', '业绩', '估值',
|
|
||||||
'商业', '卫星', '国产', '替代', '材料', '概念', '板块', '公司',
|
|
||||||
'市场', '预期', '政策', '产业', '技术', '研发', '布局', '龙头',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取最近一个自然月内的所有交易日
|
|
||||||
* @param {Array} dates - dates.json 中的日期列表
|
|
||||||
* @returns {Array} - 筛选后的日期列表
|
|
||||||
*/
|
|
||||||
const getLastMonthTradingDays = (dates) => {
|
|
||||||
if (!dates || dates.length === 0) return [];
|
|
||||||
|
|
||||||
// 获取最新日期作为基准
|
|
||||||
const latestDateStr = dates[0].date; // 格式: YYYYMMDD
|
|
||||||
const latestYear = parseInt(latestDateStr.slice(0, 4));
|
|
||||||
const latestMonth = parseInt(latestDateStr.slice(4, 6));
|
|
||||||
const latestDay = parseInt(latestDateStr.slice(6, 8));
|
|
||||||
const latestDate = new Date(latestYear, latestMonth - 1, latestDay);
|
|
||||||
|
|
||||||
// 计算一个月前的日期
|
|
||||||
const oneMonthAgo = new Date(latestDate);
|
|
||||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
|
||||||
|
|
||||||
// 筛选在一个月内的交易日
|
|
||||||
return dates.filter((dateInfo) => {
|
|
||||||
const dateStr = dateInfo.date;
|
|
||||||
const year = parseInt(dateStr.slice(0, 4));
|
|
||||||
const month = parseInt(dateStr.slice(4, 6));
|
|
||||||
const day = parseInt(dateStr.slice(6, 8));
|
|
||||||
const date = new Date(year, month - 1, day);
|
|
||||||
return date >= oneMonthAgo && date <= latestDate;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 噪音板块黑名单(过滤无意义的板块)
|
||||||
|
const NOISE_SECTORS = [
|
||||||
|
'公告', '业绩预告', '重大合同签署', '重组', '股权激励',
|
||||||
|
'增持', '回购', '解禁', '减持', '限售股解禁',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 public/data/zt 读取多日词频数据(最近一个自然月)
|
* 从 public/data/zt 读取最近两天数据用于比较趋势
|
||||||
*/
|
*/
|
||||||
const fetchMultiDayWordFreq = async () => {
|
const fetchSectorData = async () => {
|
||||||
try {
|
try {
|
||||||
// 先获取日期列表
|
// 获取日期列表
|
||||||
const datesRes = await fetch('/data/zt/dates.json');
|
const datesRes = await fetch('/data/zt/dates.json');
|
||||||
const datesData = await datesRes.json();
|
const datesData = await datesRes.json();
|
||||||
|
const recentDates = datesData.dates.slice(0, 2); // 最近两天
|
||||||
|
|
||||||
// 获取最近一个自然月的交易日
|
if (recentDates.length === 0) return { today: null, yesterday: null };
|
||||||
const monthDates = getLastMonthTradingDays(datesData.dates);
|
|
||||||
|
|
||||||
// 并行获取每日数据
|
// 获取今日数据
|
||||||
const dailyDataPromises = monthDates.map(async (dateInfo) => {
|
const todayRes = await fetch(`/data/zt/daily/${recentDates[0].date}.json`);
|
||||||
|
const todayData = await todayRes.json();
|
||||||
|
|
||||||
|
// 获取昨日数据(用于计算趋势)
|
||||||
|
let yesterdayData = null;
|
||||||
|
if (recentDates.length > 1) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/data/zt/daily/${dateInfo.date}.json`);
|
const yesterdayRes = await fetch(`/data/zt/daily/${recentDates[1].date}.json`);
|
||||||
if (!res.ok) return null;
|
yesterdayData = await yesterdayRes.json();
|
||||||
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 {
|
} catch {
|
||||||
return null;
|
// 忽略
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.all(dailyDataPromises);
|
return {
|
||||||
return results.filter(Boolean).reverse(); // 按时间正序排列
|
today: {
|
||||||
|
date: recentDates[0].formatted_date,
|
||||||
|
sectorData: todayData.sector_data || {},
|
||||||
|
stocks: todayData.stocks || [],
|
||||||
|
},
|
||||||
|
yesterday: yesterdayData ? {
|
||||||
|
date: recentDates[1].formatted_date,
|
||||||
|
sectorData: yesterdayData.sector_data || {},
|
||||||
|
stocks: yesterdayData.stocks || [],
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取词频数据失败:', error);
|
console.error('获取板块数据失败:', error);
|
||||||
return [];
|
return { today: null, yesterday: null };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理词频数据,提取核心主题的时间序列
|
* 处理板块数据,计算散点图坐标
|
||||||
*/
|
*/
|
||||||
const processWordFreqData = (dailyData) => {
|
const processSectorData = (data) => {
|
||||||
if (!dailyData || dailyData.length === 0) return { themes: [], dates: [] };
|
if (!data.today) return [];
|
||||||
|
|
||||||
const dates = dailyData.map((d) => d.formattedDate.slice(5)); // MM-DD 格式
|
const { sectorData, stocks } = data.today;
|
||||||
const themes = [];
|
const yesterdaySectors = data.yesterday?.sectorData || {};
|
||||||
|
|
||||||
CORE_THEMES.forEach((theme) => {
|
// 构建股票代码到连板数的映射
|
||||||
const values = dailyData.map((day) => {
|
const stockContinuousDays = {};
|
||||||
// 查找匹配的词频
|
stocks.forEach((stock) => {
|
||||||
const found = day.wordFreq.find((w) => w.name.includes(theme.word));
|
// continuous_days 可能是 "1板" 或数字
|
||||||
return found ? found.value : 0;
|
let days = 1;
|
||||||
|
if (stock.continuous_days) {
|
||||||
|
const match = String(stock.continuous_days).match(/(\d+)/);
|
||||||
|
if (match) days = parseInt(match[1]);
|
||||||
|
}
|
||||||
|
stockContinuousDays[stock.scode] = days;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 归一化到 0-100
|
const sectors = [];
|
||||||
const maxVal = Math.max(...values, 1);
|
|
||||||
const normalizedValues = values.map((v) => Math.round((v / maxVal) * 100));
|
|
||||||
|
|
||||||
// 计算趋势(最近两天的变化)
|
Object.entries(sectorData).forEach(([sectorName, sectorInfo]) => {
|
||||||
const trend =
|
// 过滤噪音板块
|
||||||
normalizedValues.length >= 2
|
if (NOISE_SECTORS.some((noise) => sectorName.includes(noise))) return;
|
||||||
? normalizedValues[normalizedValues.length - 1] -
|
|
||||||
normalizedValues[normalizedValues.length - 2]
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
themes.push({
|
const stockCodes = sectorInfo.stock_codes || [];
|
||||||
...theme,
|
const count = sectorInfo.count || stockCodes.length;
|
||||||
values: normalizedValues,
|
|
||||||
rawValues: values,
|
// 计算该板块的最高连板数(辨识度)
|
||||||
|
let maxContinuousDays = 1;
|
||||||
|
stockCodes.forEach((code) => {
|
||||||
|
const days = stockContinuousDays[code] || 1;
|
||||||
|
if (days > maxContinuousDays) maxContinuousDays = days;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取昨日数据计算趋势
|
||||||
|
const yesterdayCount = yesterdaySectors[sectorName]?.count || 0;
|
||||||
|
const trend = count - yesterdayCount;
|
||||||
|
|
||||||
|
// 判断板块状态
|
||||||
|
let status;
|
||||||
|
if (maxContinuousDays >= 3 && trend > 0) {
|
||||||
|
status = SECTOR_STATUS.RISING; // 主升:高辨识度+上升
|
||||||
|
} else if (maxContinuousDays >= 3 && trend < 0) {
|
||||||
|
status = SECTOR_STATUS.DECLINING; // 退潮:高辨识度+下降
|
||||||
|
} else if (maxContinuousDays < 3 && trend > 0) {
|
||||||
|
status = SECTOR_STATUS.LURKING; // 潜伏:低辨识度+上升
|
||||||
|
} else {
|
||||||
|
status = SECTOR_STATUS.CLUSTERING; // 抱团:其他
|
||||||
|
}
|
||||||
|
|
||||||
|
sectors.push({
|
||||||
|
name: sectorName,
|
||||||
|
x: maxContinuousDays, // X轴:最高板高度
|
||||||
|
y: count, // Y轴:涨停家数
|
||||||
trend,
|
trend,
|
||||||
currentValue: normalizedValues[normalizedValues.length - 1] || 0,
|
status,
|
||||||
|
stockCodes,
|
||||||
|
stocks: stockCodes.map((code) => {
|
||||||
|
const stockInfo = stocks.find((s) => s.scode === code);
|
||||||
|
return stockInfo || { scode: code, sname: code };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 按当前热度排序
|
// 按热度排序
|
||||||
themes.sort((a, b) => b.currentValue - a.currentValue);
|
sectors.sort((a, b) => b.y - a.y);
|
||||||
|
|
||||||
return { themes, dates, dailyData };
|
return sectors;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成 ECharts 配置
|
* 生成 ECharts 配置
|
||||||
*/
|
*/
|
||||||
const generateChartOption = (themes, dates, onThemeClick) => {
|
const generateChartOption = (sectors) => {
|
||||||
if (!themes || themes.length === 0) {
|
if (!sectors || sectors.length === 0) return {};
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只显示 Top 6 主题
|
// 按状态分组
|
||||||
const topThemes = themes.slice(0, 6);
|
const groupedData = {
|
||||||
|
[SECTOR_STATUS.RISING.name]: [],
|
||||||
|
[SECTOR_STATUS.DECLINING.name]: [],
|
||||||
|
[SECTOR_STATUS.LURKING.name]: [],
|
||||||
|
[SECTOR_STATUS.CLUSTERING.name]: [],
|
||||||
|
};
|
||||||
|
|
||||||
const series = [];
|
sectors.forEach((sector) => {
|
||||||
|
groupedData[sector.status.name].push({
|
||||||
topThemes.forEach((theme, index) => {
|
name: sector.name,
|
||||||
// 主线条 - 带渐变的折线
|
value: [sector.x, sector.y],
|
||||||
series.push({
|
trend: sector.trend,
|
||||||
name: theme.label,
|
stockCount: sector.y,
|
||||||
type: 'line',
|
maxBoard: sector.x,
|
||||||
smooth: true,
|
stocks: sector.stocks,
|
||||||
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 series = Object.entries(SECTOR_STATUS).map(([key, status]) => ({
|
||||||
const effectData = new Array(theme.values.length - 1).fill(null);
|
name: status.name,
|
||||||
effectData.push(lastValue);
|
type: 'scatter',
|
||||||
|
data: groupedData[status.name],
|
||||||
series.push({
|
symbolSize: (data) => {
|
||||||
name: `${theme.label}-head`,
|
// 根据热度调整大小
|
||||||
type: 'effectScatter',
|
const size = Math.max(20, Math.min(60, data[1] * 4));
|
||||||
coordinateSystem: 'cartesian2d',
|
return size;
|
||||||
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: {
|
itemStyle: {
|
||||||
color: theme.color,
|
color: status.color,
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowColor: status.color,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
opacity: 1,
|
||||||
shadowBlur: 20,
|
shadowBlur: 20,
|
||||||
shadowColor: theme.color,
|
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
formatter: theme.label,
|
},
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
formatter: (params) => params.data.name,
|
||||||
position: 'right',
|
position: 'right',
|
||||||
color: theme.color,
|
color: '#fff',
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: 'bold',
|
|
||||||
textShadowColor: 'rgba(0,0,0,0.8)',
|
textShadowColor: 'rgba(0,0,0,0.8)',
|
||||||
textShadowBlur: 4,
|
textShadowBlur: 4,
|
||||||
},
|
},
|
||||||
z: 20 + index,
|
}));
|
||||||
});
|
|
||||||
});
|
// 计算坐标轴范围
|
||||||
|
const maxX = Math.max(...sectors.map((s) => s.x), 5) + 1;
|
||||||
|
const maxY = Math.max(...sectors.map((s) => s.y), 10) + 2;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'item',
|
||||||
backgroundColor: 'rgba(15, 15, 30, 0.95)',
|
backgroundColor: 'rgba(15, 15, 30, 0.95)',
|
||||||
borderColor: 'rgba(255, 215, 0, 0.3)',
|
borderColor: 'rgba(255, 215, 0, 0.3)',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -248,40 +225,71 @@ const generateChartOption = (themes, dates, onThemeClick) => {
|
|||||||
color: '#fff',
|
color: '#fff',
|
||||||
},
|
},
|
||||||
formatter: (params) => {
|
formatter: (params) => {
|
||||||
if (!params || params.length === 0) return '';
|
const { name, value, trend, stocks } = params.data;
|
||||||
const dateIndex = params[0].dataIndex;
|
const trendText = trend > 0 ? `+${trend}` : trend;
|
||||||
const date = dates[dateIndex];
|
|
||||||
let html = `<div style="font-weight:bold;margin-bottom:8px;color:#FFD700">${date}</div>`;
|
|
||||||
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 ? '❄️' : '➡️';
|
const trendIcon = trend > 0 ? '🔥' : trend < 0 ? '❄️' : '➡️';
|
||||||
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0;">
|
|
||||||
<span style="color:${theme.color}">${p.seriesName}</span>
|
// 显示前5只股票
|
||||||
<span style="margin-left:20px">${p.value} ${trendIcon}</span>
|
const topStocks = stocks.slice(0, 5);
|
||||||
</div>`;
|
const stockList = topStocks
|
||||||
}
|
.map((s) => `${s.sname || s.scode}`)
|
||||||
});
|
.join('、');
|
||||||
return html;
|
|
||||||
|
return `
|
||||||
|
<div style="font-weight:bold;margin-bottom:8px;color:#FFD700;font-size:14px;">
|
||||||
|
${name} ${trendIcon}
|
||||||
|
</div>
|
||||||
|
<div style="margin:4px 0;">
|
||||||
|
<span style="color:#aaa">涨停家数:</span>
|
||||||
|
<span style="color:#fff;margin-left:8px;">${value[1]}家</span>
|
||||||
|
<span style="color:${trend >= 0 ? '#52c41a' : '#ff4d4f'};margin-left:8px;">(${trendText})</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin:4px 0;">
|
||||||
|
<span style="color:#aaa">最高连板:</span>
|
||||||
|
<span style="color:#fff;margin-left:8px;">${value[0]}板</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin:4px 0;color:#aaa;font-size:11px;">
|
||||||
|
${stockList}${stocks.length > 5 ? '...' : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
show: false,
|
show: true,
|
||||||
|
top: 10,
|
||||||
|
right: 10,
|
||||||
|
orient: 'horizontal',
|
||||||
|
textStyle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.7)',
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
itemWidth: 14,
|
||||||
|
itemHeight: 14,
|
||||||
|
data: Object.values(SECTOR_STATUS).map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
icon: 'circle',
|
||||||
|
itemStyle: { color: s.color },
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
left: '3%',
|
left: '8%',
|
||||||
right: '15%',
|
right: '5%',
|
||||||
top: '10%',
|
top: '15%',
|
||||||
bottom: '12%',
|
bottom: '15%',
|
||||||
containLabel: true,
|
containLabel: true,
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'value',
|
||||||
data: dates,
|
name: '辨识度(最高板)',
|
||||||
boundaryGap: false,
|
nameLocation: 'middle',
|
||||||
|
nameGap: 30,
|
||||||
|
nameTextStyle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
min: 0,
|
||||||
|
max: maxX,
|
||||||
|
interval: 1,
|
||||||
axisLine: {
|
axisLine: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: 'rgba(255, 255, 255, 0.2)',
|
color: 'rgba(255, 255, 255, 0.2)',
|
||||||
@@ -289,25 +297,32 @@ const generateChartOption = (themes, dates, onThemeClick) => {
|
|||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: 'rgba(255, 255, 255, 0.6)',
|
color: 'rgba(255, 255, 255, 0.6)',
|
||||||
fontSize: 10,
|
fontSize: 11,
|
||||||
interval: Math.max(0, Math.floor(dates.length / 8) - 1), // 动态间隔,约显示8个标签
|
formatter: '{value}板',
|
||||||
rotate: dates.length > 15 ? 30 : 0, // 数据多时旋转标签
|
|
||||||
},
|
},
|
||||||
splitLine: {
|
splitLine: {
|
||||||
show: false,
|
lineStyle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
|
name: '板块热度(家数)',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 40,
|
||||||
|
nameTextStyle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: maxY,
|
||||||
axisLine: {
|
axisLine: {
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: 'rgba(255, 255, 255, 0.4)',
|
color: 'rgba(255, 255, 255, 0.6)',
|
||||||
fontSize: 10,
|
fontSize: 11,
|
||||||
formatter: '{value}',
|
|
||||||
},
|
},
|
||||||
splitLine: {
|
splitLine: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
@@ -324,8 +339,8 @@ const generateChartOption = (themes, dates, onThemeClick) => {
|
|||||||
*/
|
*/
|
||||||
const ThemeCometChart = ({ onThemeSelect }) => {
|
const ThemeCometChart = ({ onThemeSelect }) => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [data, setData] = useState({ themes: [], dates: [], dailyData: [] });
|
const [sectors, setSectors] = useState([]);
|
||||||
const [selectedTheme, setSelectedTheme] = useState(null);
|
const [dateInfo, setDateInfo] = useState('');
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
@@ -333,11 +348,14 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const dailyData = await fetchMultiDayWordFreq();
|
const data = await fetchSectorData();
|
||||||
const processed = processWordFreqData(dailyData);
|
const processed = processSectorData(data);
|
||||||
setData(processed);
|
setSectors(processed);
|
||||||
|
if (data.today) {
|
||||||
|
setDateInfo(data.today.date);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载词频数据失败:', error);
|
console.error('加载板块数据失败:', error);
|
||||||
toast({
|
toast({
|
||||||
title: '加载数据失败',
|
title: '加载数据失败',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
@@ -352,46 +370,34 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
|
|
||||||
// 图表配置
|
// 图表配置
|
||||||
const chartOption = useMemo(() => {
|
const chartOption = useMemo(() => {
|
||||||
return generateChartOption(data.themes, data.dates, setSelectedTheme);
|
return generateChartOption(sectors);
|
||||||
}, [data]);
|
}, [sectors]);
|
||||||
|
|
||||||
// 点击主题
|
// 点击事件
|
||||||
const handleThemeClick = useCallback(
|
const handleChartClick = useCallback(
|
||||||
(theme) => {
|
(params) => {
|
||||||
setSelectedTheme(theme);
|
if (params.data && onThemeSelect) {
|
||||||
if (onThemeSelect) {
|
const sector = sectors.find((s) => s.name === params.data.name);
|
||||||
// 找出该主题相关的股票
|
if (sector) {
|
||||||
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({
|
onThemeSelect({
|
||||||
theme,
|
theme: { label: sector.name, color: sector.status.color },
|
||||||
stocks: relatedStocks,
|
stocks: sector.stocks.map((s) => ({
|
||||||
date: latestDay.formattedDate,
|
...s,
|
||||||
|
_continuousDays: 1,
|
||||||
|
})),
|
||||||
|
date: dateInfo,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[data, onThemeSelect]
|
[sectors, dateInfo, onThemeSelect]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 图表事件
|
|
||||||
const onChartEvents = useMemo(
|
const onChartEvents = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
click: (params) => {
|
click: handleChartClick,
|
||||||
if (params.seriesName && !params.seriesName.includes('-head')) {
|
|
||||||
const theme = data.themes.find((t) => t.label === params.seriesName);
|
|
||||||
if (theme) {
|
|
||||||
handleThemeClick(theme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
[data.themes, handleThemeClick]
|
[handleChartClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -399,16 +405,16 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
<Center h="300px">
|
<Center h="300px">
|
||||||
<VStack spacing={4}>
|
<VStack spacing={4}>
|
||||||
<Spinner size="lg" color="yellow.400" />
|
<Spinner size="lg" color="yellow.400" />
|
||||||
<Text color="whiteAlpha.600">加载词频数据...</Text>
|
<Text color="whiteAlpha.600">加载板块数据...</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.themes || data.themes.length === 0) {
|
if (!sectors || sectors.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Center h="300px">
|
<Center h="300px">
|
||||||
<Text color="whiteAlpha.500">暂无词频数据</Text>
|
<Text color="whiteAlpha.500">暂无板块数据</Text>
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -423,7 +429,7 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
minH="350px"
|
minH="350px"
|
||||||
>
|
>
|
||||||
{/* 标题栏 */}
|
{/* 标题栏 */}
|
||||||
<HStack spacing={3} mb={4}>
|
<HStack spacing={3} mb={2}>
|
||||||
<Box
|
<Box
|
||||||
p={2}
|
p={2}
|
||||||
bg="rgba(255,215,0,0.15)"
|
bg="rgba(255,215,0,0.15)"
|
||||||
@@ -434,16 +440,16 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
<VStack align="start" spacing={0}>
|
<VStack align="start" spacing={0}>
|
||||||
<Text fontSize="md" fontWeight="bold" color="#FFD700">
|
<Text fontSize="md" fontWeight="bold" color="#FFD700">
|
||||||
题材热力追踪
|
AI 舆情 · 时空决策驾驶舱
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="xs" color="whiteAlpha.500">
|
<Text fontSize="xs" color="whiteAlpha.500">
|
||||||
核心主题词频演变 · 最近一个月
|
题材流星图 · {dateInfo}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{/* 图表区域 */}
|
{/* 图表区域 */}
|
||||||
<Box h="calc(100% - 80px)">
|
<Box h="calc(100% - 60px)">
|
||||||
<ReactECharts
|
<ReactECharts
|
||||||
option={chartOption}
|
option={chartOption}
|
||||||
style={{ height: '100%', width: '100%' }}
|
style={{ height: '100%', width: '100%' }}
|
||||||
@@ -451,38 +457,6 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
opts={{ renderer: 'canvas' }}
|
opts={{ renderer: 'canvas' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 底部图例 */}
|
|
||||||
<HStack spacing={2} flexWrap="wrap" mt={2}>
|
|
||||||
{data.themes.slice(0, 6).map((theme) => (
|
|
||||||
<Tooltip
|
|
||||||
key={theme.word}
|
|
||||||
label={`当前热度: ${theme.currentValue} | 趋势: ${theme.trend > 0 ? '+' : ''}${theme.trend}`}
|
|
||||||
placement="top"
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
px={2}
|
|
||||||
py={1}
|
|
||||||
borderRadius="full"
|
|
||||||
bg={`${theme.color}20`}
|
|
||||||
color={theme.color}
|
|
||||||
border={`1px solid ${theme.color}50`}
|
|
||||||
cursor="pointer"
|
|
||||||
fontSize="xs"
|
|
||||||
onClick={() => handleThemeClick(theme)}
|
|
||||||
_hover={{
|
|
||||||
bg: `${theme.color}40`,
|
|
||||||
transform: 'scale(1.05)',
|
|
||||||
}}
|
|
||||||
transition="all 0.2s"
|
|
||||||
>
|
|
||||||
{theme.label}
|
|
||||||
{theme.trend > 5 && ' 🔥'}
|
|
||||||
{theme.trend < -5 && ' ❄️'}
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</HStack>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user