feat: StockChartModal.tsx 替换 KLine 实现

This commit is contained in:
zdl
2025-11-24 13:59:29 +08:00
parent b4dcbd1db9
commit 2f125a9207
10 changed files with 768 additions and 630 deletions

View File

@@ -1,5 +1,5 @@
// src/components/StockChart/StockChartModal.tsx - 统一的股票图表组件 // src/components/StockChart/StockChartModal.tsx - 统一的股票图表组件KLineChart 实现)
import React, { useState, useEffect, useRef } from 'react'; import React, { useState } from 'react';
import { import {
Modal, Modal,
ModalOverlay, ModalOverlay,
@@ -17,44 +17,17 @@ import {
Flex, Flex,
CircularProgress, CircularProgress,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import * as echarts from 'echarts';
import type { EChartsOption, ECharts } from 'echarts';
import dayjs from 'dayjs';
import { stockService } from '../../services/eventService';
import { logger } from '../../utils/logger';
import RiskDisclaimer from '../RiskDisclaimer'; import RiskDisclaimer from '../RiskDisclaimer';
import { RelationDescription } from '../StockRelation'; import { RelationDescription } from '../StockRelation';
import type { RelationDescType } from '../StockRelation'; import type { RelationDescType } from '../StockRelation';
import { useKLineChart, useKLineData, useEventMarker } from './hooks';
import { Alert, AlertIcon } from '@chakra-ui/react';
/** /**
* 图表类型 * 图表类型
*/ */
type ChartType = 'timeline' | 'daily'; type ChartType = 'timeline' | 'daily';
/**
* K线数据项
*/
interface KLineDataItem {
time: string;
date?: string;
open: number;
high: number;
low: number;
close: number;
price?: number;
volume: number;
avg_price?: number;
}
/**
* 图表数据结构
*/
interface ChartData {
data: KLineDataItem[];
trade_date: string;
prev_close?: number;
}
/** /**
* 股票信息 * 股票信息
*/ */
@@ -90,12 +63,6 @@ export interface StockChartModalProps {
initialChartType?: ChartType; initialChartType?: ChartType;
} }
/**
* ECharts 实例(带自定义 resizeHandler
*/
interface EChartsInstance extends ECharts {
resizeHandler?: () => void;
}
const StockChartModal: React.FC<StockChartModalProps> = ({ const StockChartModal: React.FC<StockChartModalProps> = ({
isOpen, isOpen,
@@ -106,529 +73,47 @@ const StockChartModal: React.FC<StockChartModalProps> = ({
size = '6xl', size = '6xl',
initialChartType = 'timeline', initialChartType = 'timeline',
}) => { }) => {
const chartRef = useRef<HTMLDivElement>(null); // 状态管理
const chartInstanceRef = useRef<EChartsInstance | null>(null);
const [chartType, setChartType] = useState<ChartType>(initialChartType); const [chartType, setChartType] = useState<ChartType>(initialChartType);
const [loading, setLoading] = useState(false);
const [chartData, setChartData] = useState<ChartData | null>(null); // KLineChart Hooks
const [preloadedData, setPreloadedData] = useState<Record<ChartType, ChartData | undefined>>({ const { chart, chartRef, isInitialized, error: chartError } = useKLineChart({
timeline: undefined, containerId: `kline-chart-${stock?.stock_code || 'default'}`,
daily: undefined, height: 500,
autoResize: true,
chartType, // ✅ 传递 chartType让 Hook 根据类型应用不同样式
}); });
// 预加载数据 const { data, loading, error: dataError } = useKLineData({
const preloadData = async (type: ChartType): Promise<void> => { chart,
if (!stock || preloadedData[type]) return; stockCode: stock?.stock_code || '',
chartType,
eventTime: eventTime || undefined,
autoLoad: true, // 改为 true让 Hook 内部根据 stockCode 和 chart 判断是否加载
});
try { const { marker } = useEventMarker({
let adjustedEventTime = eventTime; chart,
if (eventTime) { data,
try { eventTime: eventTime || undefined,
const eventMoment = dayjs(eventTime); eventTitle: '事件发生',
if (eventMoment.isValid() && eventMoment.hour() >= 15) { autoCreate: true,
const nextDay = eventMoment.clone().add(1, 'day'); });
nextDay.hour(9).minute(30).second(0).millisecond(0);
adjustedEventTime = nextDay.format('YYYY-MM-DD HH:mm');
}
} catch (e) {
logger.warn('StockChartModal', '事件时间解析失败', {
eventTime,
error: (e as Error).message,
});
}
}
const response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime);
setPreloadedData((prev) => ({ ...prev, [type]: response }));
} catch (err) {
logger.error('StockChartModal', 'preloadData', err, {
stockCode: stock?.stock_code,
type,
});
}
};
useEffect(() => {
if (isOpen && stock) {
// 预加载两种图表类型的数据
preloadData('timeline');
preloadData('daily');
// 清理图表实例
return () => {
if (chartInstanceRef.current) {
window.removeEventListener('resize', chartInstanceRef.current.resizeHandler!);
chartInstanceRef.current.dispose();
chartInstanceRef.current = null;
}
};
}
}, [isOpen, stock, eventTime]);
useEffect(() => {
if (isOpen && stock) {
loadChartData(chartType);
}
}, [chartType, isOpen, stock]);
const loadChartData = async (type: ChartType): Promise<void> => {
if (!stock) return;
try {
setLoading(true);
// 先尝试使用预加载的数据
let response = preloadedData[type];
if (!response) {
// 如果预加载数据不存在,则立即请求
let adjustedEventTime = eventTime;
if (eventTime) {
try {
const eventMoment = dayjs(eventTime);
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
const nextDay = eventMoment.clone().add(1, 'day');
nextDay.hour(9).minute(30).second(0).millisecond(0);
adjustedEventTime = nextDay.format('YYYY-MM-DD HH:mm');
}
} catch (e) {
logger.warn('StockChartModal', '事件时间解析失败', {
eventTime,
error: (e as Error).message,
});
}
}
response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime);
}
setChartData(response);
// 初始化图表
if (chartRef.current && !chartInstanceRef.current) {
const chart = echarts.init(chartRef.current) as EChartsInstance;
chart.resizeHandler = () => chart.resize();
window.addEventListener('resize', chart.resizeHandler);
chartInstanceRef.current = chart;
}
if (chartInstanceRef.current) {
const option = generateChartOption(response, type, eventTime);
chartInstanceRef.current.setOption(option, true);
}
} catch (err) {
logger.error('StockChartModal', 'loadChartData', err, {
stockCode: stock?.stock_code,
chartType: type,
});
} finally {
setLoading(false);
}
};
const generateChartOption = (
data: ChartData,
type: ChartType,
originalEventTime?: string | null
): EChartsOption => {
if (!data || !data.data || data.data.length === 0) {
return {
title: {
text: '暂无数据',
left: 'center',
top: 'center',
textStyle: { color: '#999', fontSize: 16 },
},
};
}
const stockData = data.data;
const tradeDate = data.trade_date;
// 分时图
if (type === 'timeline') {
const times = stockData.map((item) => item.time);
const prices = stockData.map((item) => item.close || item.price || 0);
const avgPrices = stockData.map((item) => item.avg_price || 0);
const volumes = stockData.map((item) => item.volume);
// 获取昨收盘价作为基准
const prevClose = data.prev_close || (prices.length > 0 ? prices[0] : 0);
// 计算涨跌幅数据
const changePercentData = prices.map((price) => ((price - prevClose) / prevClose) * 100);
const avgChangePercentData = avgPrices.map(
(avgPrice) => ((avgPrice - prevClose) / prevClose) * 100
);
const currentPrice = prices[prices.length - 1];
const currentChange = ((currentPrice - prevClose) / prevClose) * 100;
const isUp = currentChange >= 0;
const lineColor = isUp ? '#ef5350' : '#26a69a';
// 计算事件标记线位置
const eventMarkLineData: any[] = [];
if (originalEventTime && times.length > 0) {
const eventMoment = dayjs(originalEventTime);
const eventDate = eventMoment.format('YYYY-MM-DD');
if (eventDate === tradeDate) {
// 找到最接近的时间点
let nearestIdx = 0;
const eventMinutes = eventMoment.hour() * 60 + eventMoment.minute();
for (let i = 0; i < times.length; i++) {
const [h, m] = times[i].split(':').map(Number);
const timeMinutes = h * 60 + m;
const currentDiff = Math.abs(timeMinutes - eventMinutes);
const nearestDiff = Math.abs(
times[nearestIdx].split(':').map(Number)[0] * 60 +
times[nearestIdx].split(':').map(Number)[1] -
eventMinutes
);
if (currentDiff < nearestDiff) {
nearestIdx = i;
}
}
eventMarkLineData.push({
name: '事件发生',
xAxis: nearestIdx,
label: {
formatter: '事件发生',
position: 'middle',
color: '#FFD700',
fontSize: 12,
},
lineStyle: {
color: '#FFD700',
type: 'solid',
width: 2,
},
});
}
}
return {
title: {
text: `${stock!.stock_name || stock!.stock_code} - 分时图`,
left: 'center',
textStyle: { fontSize: 16, fontWeight: 'bold' },
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: function (params: any) {
if (!params || params.length === 0) return '';
const point = params[0];
const idx = point.dataIndex;
const priceChangePercent = ((prices[idx] - prevClose) / prevClose) * 100;
const avgChangePercent = ((avgPrices[idx] - prevClose) / prevClose) * 100;
const priceColor = priceChangePercent >= 0 ? '#ef5350' : '#26a69a';
const avgColor = avgChangePercent >= 0 ? '#ef5350' : '#26a69a';
return `时间:${times[idx]}<br/>现价:<span style="color: ${priceColor}">¥${prices[
idx
]?.toFixed(2)} (${priceChangePercent >= 0 ? '+' : ''}${priceChangePercent.toFixed(
2
)}%)</span><br/>均价:<span style="color: ${avgColor}">¥${avgPrices[idx]?.toFixed(
2
)} (${avgChangePercent >= 0 ? '+' : ''}${avgChangePercent.toFixed(
2
)}%)</span><br/>昨收:¥${prevClose?.toFixed(2)}<br/>成交量:${Math.round(
volumes[idx] / 100
)}`;
},
},
grid: [
{ left: '10%', right: '10%', height: '60%', top: '15%' },
{ left: '10%', right: '10%', top: '80%', height: '15%' },
],
xAxis: [
{ type: 'category', data: times, gridIndex: 0, boundaryGap: false },
{ type: 'category', data: times, gridIndex: 1, axisLabel: { show: false } },
],
yAxis: [
{
type: 'value',
gridIndex: 0,
scale: false,
position: 'left',
axisLabel: {
formatter: function (value: number) {
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
},
},
splitLine: {
show: true,
lineStyle: {
color: '#f0f0f0',
},
},
},
{
type: 'value',
gridIndex: 0,
scale: false,
position: 'right',
axisLabel: {
formatter: function (value: number) {
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
},
},
},
{
type: 'value',
gridIndex: 1,
scale: true,
axisLabel: { formatter: (v: number) => Math.round(v / 100) + '手' },
},
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100 },
{ show: true, xAxisIndex: [0, 1], type: 'slider', bottom: '0%' },
],
series: [
{
name: '分时价',
type: 'line',
xAxisIndex: 0,
yAxisIndex: 0,
data: changePercentData,
smooth: true,
showSymbol: false,
lineStyle: { color: lineColor, width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: isUp ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)',
},
{
offset: 1,
color: isUp ? 'rgba(239, 83, 80, 0.05)' : 'rgba(38, 166, 154, 0.05)',
},
]),
},
markLine: {
symbol: 'none',
data: [
// 昨收盘价基准线 (0%)
{
yAxis: 0,
lineStyle: {
color: '#666',
type: 'dashed',
width: 1.5,
opacity: 0.8,
},
label: {
show: true,
formatter: '昨收盘价',
position: 'insideEndTop',
color: '#666',
fontSize: 12,
},
},
...eventMarkLineData,
],
animation: false,
},
},
{
name: '均价线',
type: 'line',
xAxisIndex: 0,
yAxisIndex: 1,
data: avgChangePercentData,
smooth: true,
showSymbol: false,
lineStyle: { color: '#FFA500', width: 1 },
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 2,
data: volumes,
itemStyle: { color: '#b0c4de', opacity: 0.6 },
},
],
};
}
// 日K线
if (type === 'daily') {
const dates = stockData.map((item) => item.time || item.date || '');
const klineData = stockData.map((item) => [item.open, item.close, item.low, item.high]);
const volumes = stockData.map((item) => item.volume);
// 计算事件标记线位置
const eventMarkLineData: any[] = [];
if (originalEventTime && dates.length > 0) {
const eventMoment = dayjs(originalEventTime);
const eventDate = eventMoment.format('YYYY-MM-DD');
// 找到事件发生日期或最接近的交易日
let targetIndex = -1;
// 1. 先尝试找到完全匹配的日期
targetIndex = dates.findIndex((date) => date === eventDate);
// 2. 如果没有完全匹配,找到第一个大于等于事件日期的交易日
if (targetIndex === -1) {
for (let i = 0; i < dates.length; i++) {
if (dates[i] >= eventDate) {
targetIndex = i;
break;
}
}
}
// 3. 如果事件日期晚于所有交易日,则标记在最后一个交易日
if (targetIndex === -1 && eventDate > dates[dates.length - 1]) {
targetIndex = dates.length - 1;
}
// 4. 如果事件日期早于所有交易日,则标记在第一个交易日
if (targetIndex === -1 && eventDate < dates[0]) {
targetIndex = 0;
}
if (targetIndex >= 0) {
let labelText = '事件发生';
let labelPosition: any = 'middle';
// 根据事件时间和交易日的关系调整标签
if (eventDate === dates[targetIndex]) {
if (eventMoment.hour() >= 15) {
labelText = '事件发生\n(盘后)';
} else if (eventMoment.hour() < 9 || (eventMoment.hour() === 9 && eventMoment.minute() < 30)) {
labelText = '事件发生\n(盘前)';
}
} else if (eventDate < dates[targetIndex]) {
labelText = '事件发生\n(前一日)';
labelPosition = 'start';
} else {
labelText = '事件发生\n(影响日)';
labelPosition = 'end';
}
eventMarkLineData.push({
name: '事件发生',
xAxis: targetIndex,
label: {
formatter: labelText,
position: labelPosition,
color: '#FFD700',
fontSize: 12,
backgroundColor: 'rgba(0,0,0,0.5)',
padding: [4, 8],
borderRadius: 4,
},
lineStyle: {
color: '#FFD700',
type: 'solid',
width: 2,
},
});
}
}
return {
title: {
text: `${stock!.stock_name || stock!.stock_code} - 日K线`,
left: 'center',
textStyle: { fontSize: 16, fontWeight: 'bold' },
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: function (params: any) {
if (!params || params.length === 0) return '';
const kline = params[0];
const volume = params[1];
if (!kline || !kline.data) return '';
let tooltipHtml = `日期: ${kline.axisValue}<br/>开盘: ¥${kline.data[0]}<br/>收盘: ¥${kline.data[1]}<br/>最低: ¥${kline.data[2]}<br/>最高: ¥${kline.data[3]}`;
if (volume && volume.data) {
tooltipHtml += `<br/>成交量: ${Math.round(volume.data / 100)}`;
}
return tooltipHtml;
},
},
grid: [
{ left: '10%', right: '10%', height: '60%' },
{ left: '10%', right: '10%', top: '75%', height: '20%' },
],
xAxis: [
{ type: 'category', data: dates, boundaryGap: true, gridIndex: 0 },
{ type: 'category', data: dates, gridIndex: 1, axisLabel: { show: false } },
],
yAxis: [
{ type: 'value', scale: true, splitArea: { show: true }, gridIndex: 0 },
{
scale: true,
gridIndex: 1,
axisLabel: { formatter: (value: number) => Math.round(value / 100) + '手' },
},
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: 70, end: 100 },
{ show: true, xAxisIndex: [0, 1], type: 'slider', bottom: '0%', start: 70, end: 100 },
],
series: [
{
name: 'K线',
type: 'candlestick',
yAxisIndex: 0,
data: klineData,
markLine: {
symbol: 'none',
data: eventMarkLineData,
animation: false,
},
itemStyle: {
color: '#ef5350',
color0: '#26a69a',
borderColor: '#ef5350',
borderColor0: '#26a69a',
},
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes.map((volume, index) => ({
value: volume,
itemStyle: {
color: stockData[index].close >= stockData[index].open ? '#ef5350' : '#26a69a',
},
})),
},
],
};
}
return {};
};
// 守卫子句
if (!stock) return null; if (!stock) return null;
return ( return (
<Modal isOpen={isOpen} onClose={onClose} size={size}> <Modal isOpen={isOpen} onClose={onClose} size={size}>
<ModalOverlay /> <ModalOverlay />
<ModalContent maxW="90vw" maxH="90vh" overflow="hidden"> <ModalContent maxW="90vw" maxH="90vh" overflow="hidden">
<ModalHeader pb={4}> <ModalHeader pb={4} position="relative">
<VStack align="flex-start" spacing={2}> <VStack align="flex-start" spacing={2}>
<HStack> <HStack>
<Text fontSize="lg" fontWeight="bold"> <Text fontSize="lg" fontWeight="bold">
{stock.stock_name || stock.stock_code} ({stock.stock_code}) - {stock.stock_name || stock.stock_code} ({stock.stock_code}) -
</Text> </Text>
{chartData && <Badge colorScheme="blue">{chartData.trade_date}</Badge>} {data.length > 0 && <Badge colorScheme="blue">: {data.length}</Badge>}
</HStack> </HStack>
<ButtonGroup size="sm"> <ButtonGroup size="sm">
<Button <Button
@@ -647,11 +132,46 @@ const StockChartModal: React.FC<StockChartModalProps> = ({
</Button> </Button>
</ButtonGroup> </ButtonGroup>
</VStack> </VStack>
{/* 重件发生标签 - 仅在有 eventTime 时显示 */}
{eventTime && (
<Badge
colorScheme="yellow"
fontSize="sm"
px={3}
py={1}
borderRadius="md"
position="absolute"
top="4"
right="12"
boxShadow="sm"
>
()
</Badge>
)}
</ModalHeader> </ModalHeader>
<ModalCloseButton /> <ModalCloseButton />
<ModalBody p={0} overflowY="auto" maxH="calc(90vh - 120px)"> <ModalBody p={0} overflowY="auto" maxH="calc(90vh - 120px)">
{/* 图表区域 */} {/* 错误提示 */}
<Box h="500px" w="100%" position="relative"> {(chartError || dataError) && (
<Alert status="error" mx={4} mt={4}>
<AlertIcon />
{chartError?.message || dataError?.message}
</Alert>
)}
{/* 图表区域 - 响应式高度 */}
<Box
h={{
base: "calc(60vh - 100px)", // 移动端60% 视口高度 - 100px
md: "calc(70vh - 150px)", // 平板70% 视口高度 - 150px
lg: "calc(80vh - 200px)" // 桌面80% 视口高度 - 200px
}}
minH="350px" // 最小高度:确保可用性
maxH="650px" // 最大高度:避免过大
w="100%"
position="relative"
>
{loading && ( {loading && (
<Flex <Flex
position="absolute" position="absolute"
@@ -670,7 +190,11 @@ const StockChartModal: React.FC<StockChartModalProps> = ({
</VStack> </VStack>
</Flex> </Flex>
)} )}
<div ref={chartRef} style={{ height: '100%', width: '100%', minHeight: '500px' }} /> <div
ref={chartRef}
id={`kline-chart-${stock.stock_code}`}
style={{ width: '100%', height: '100%' }}
/>
</Box> </Box>
{/* 关联描述 */} {/* 关联描述 */}

View File

@@ -290,9 +290,193 @@ export const darkTheme: DeepPartial<Styles> = {
}, },
}; };
/**
* 分时图专用主题配置
* 特点面积图样式、均价线、百分比Y轴
*/
export const timelineTheme: DeepPartial<Styles> = {
...lightTheme,
candle: {
type: 'area', // ✅ 面积图模式(分时线)
area: {
lineSize: 2,
lineColor: CHART_COLORS.up, // 默认红色,实际会根据涨跌动态调整
value: 'close',
backgroundColor: [
{
offset: 0,
color: 'rgba(239, 83, 80, 0.2)', // 红色半透明渐变(顶部)
},
{
offset: 1,
color: 'rgba(239, 83, 80, 0.01)', // 红色几乎透明(底部)
},
],
},
priceMark: {
show: true,
high: {
show: false, // 分时图不显示最高最低价标记
},
low: {
show: false,
},
last: {
show: true,
upColor: CHART_COLORS.up,
downColor: CHART_COLORS.down,
noChangeColor: CHART_COLORS.neutral,
line: {
show: true,
style: 'dashed',
dashValue: [4, 2],
size: 1,
},
text: {
show: true,
size: 12,
paddingLeft: 4,
paddingTop: 2,
paddingRight: 4,
paddingBottom: 2,
borderRadius: 2,
},
},
},
tooltip: {
showRule: 'always',
showType: 'standard',
// ✅ 自定义 Tooltip 标签和格式化
labels: ['时间: ', '现价: ', '涨跌: ', '均价: ', '昨收: ', '成交量: '],
// 自定义格式化函数(如果 KLineChart 支持)
formatter: (data: any, indicator: any) => {
if (!data) return [];
const { timestamp, close, volume, prev_close } = data;
const time = new Date(timestamp);
const timeStr = `${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`;
// 计算涨跌幅
const changePercent = prev_close ? ((close - prev_close) / prev_close * 100).toFixed(2) : '0.00';
const changeValue = prev_close ? (close - prev_close).toFixed(2) : '0.00';
// 成交量转换为手1手 = 100股
const volumeHands = Math.floor(volume / 100);
return [
{ title: '时间', value: timeStr },
{ title: '现价', value: `¥${close?.toFixed(2) || '--'}` },
{ title: '涨跌', value: `${changeValue} (${changePercent}%)` },
{ title: '昨收', value: `¥${prev_close?.toFixed(2) || '--'}` },
{ title: '成交量', value: `${volumeHands.toLocaleString()}` },
];
},
text: {
size: 12,
family: 'Helvetica, Arial, sans-serif',
weight: 'normal',
color: CHART_COLORS.text,
},
},
},
yAxis: {
...lightTheme.yAxis,
type: 'percentage', // ✅ 百分比模式
position: 'left', // Y轴在左侧
inside: false,
reverse: false,
tickText: {
...lightTheme.yAxis?.tickText,
// ✅ 自定义 Y 轴格式化:显示百分比(如 "+2.50%", "-1.20%", "0.00%"
formatter: (value: any) => {
const percent = (value * 100).toFixed(2);
// 处理 0 值:显示 "0.00%" 而非 "-0.00%" 或 "+0.00%"
if (Math.abs(value) < 0.0001) {
return '0.00%';
}
return value > 0 ? `+${percent}%` : `${percent}%`;
},
},
},
grid: {
show: true,
horizontal: {
show: true,
size: 1,
color: CHART_COLORS.grid,
style: 'solid', // 分时图使用实线网格
},
vertical: {
show: false,
},
},
};
/**
* 分时图深色主题
*/
export const timelineThemeDark: DeepPartial<Styles> = {
...timelineTheme,
...darkTheme,
candle: {
...timelineTheme.candle,
tooltip: {
...timelineTheme.candle?.tooltip,
text: {
...timelineTheme.candle?.tooltip?.text,
color: CHART_COLORS.textDark,
},
},
},
grid: {
...timelineTheme.grid,
horizontal: {
...timelineTheme.grid?.horizontal,
color: CHART_COLORS.gridDark,
},
},
};
/** /**
* 获取主题配置(根据 Chakra UI colorMode * 获取主题配置(根据 Chakra UI colorMode
*/ */
export const getTheme = (colorMode: 'light' | 'dark' = 'light'): DeepPartial<Styles> => { export const getTheme = (colorMode: 'light' | 'dark' = 'light'): DeepPartial<Styles> => {
return colorMode === 'dark' ? darkTheme : lightTheme; return colorMode === 'dark' ? darkTheme : lightTheme;
}; };
/**
* 获取分时图主题配置
*/
export const getTimelineTheme = (colorMode: 'light' | 'dark' = 'light'): DeepPartial<Styles> => {
const baseTheme = colorMode === 'dark' ? timelineThemeDark : timelineTheme;
// ✅ 添加成交量指标样式(蓝色渐变柱状图)+ 成交量单位格式化
return {
...baseTheme,
indicator: {
...baseTheme.indicator,
bars: [
{
upColor: 'rgba(59, 130, 246, 0.6)', // 蓝色(涨)
downColor: 'rgba(59, 130, 246, 0.6)', // 蓝色(跌)- 分时图成交量统一蓝色
noChangeColor: 'rgba(59, 130, 246, 0.6)',
}
],
// ✅ 自定义 Tooltip 格式化(显示完整信息)
tooltip: {
...baseTheme.indicator?.tooltip,
// 格式化成交量数值:显示为"手"1手 = 100股
formatter: (params: any) => {
if (params.name === 'VOL' && params.calcParamsText) {
const volume = params.calcParamsText.match(/\d+/)?.[0];
if (volume) {
const hands = Math.floor(Number(volume) / 100);
return `成交量: ${hands.toLocaleString()}`;
}
}
return params.calcParamsText || '';
},
},
},
};
};

View File

@@ -10,6 +10,7 @@ import type { EventMarker, KLineDataPoint } from '../types';
import { import {
createEventMarkerFromTime, createEventMarkerFromTime,
createEventMarkerOverlay, createEventMarkerOverlay,
createEventHighlightOverlay,
removeAllEventMarkers, removeAllEventMarkers,
} from '../utils/eventMarkerUtils'; } from '../utils/eventMarkerUtils';
import { logger } from '@utils/logger'; import { logger } from '@utils/logger';
@@ -68,6 +69,7 @@ export const useEventMarker = (
const [marker, setMarker] = useState<EventMarker | null>(null); const [marker, setMarker] = useState<EventMarker | null>(null);
const [markerId, setMarkerId] = useState<string | null>(null); const [markerId, setMarkerId] = useState<string | null>(null);
const [highlightId, setHighlightId] = useState<string | null>(null);
/** /**
* 创建事件标记 * 创建事件标记
@@ -110,6 +112,18 @@ export const useEventMarker = (
const actualId = Array.isArray(id) ? id[0] : id; const actualId = Array.isArray(id) ? id[0] : id;
setMarkerId(actualId as string); setMarkerId(actualId as string);
// 4. 创建黄色高亮背景(事件影响日)
const highlightOverlay = createEventHighlightOverlay(time, data);
if (highlightOverlay) {
const highlightResult = chart.createOverlay(highlightOverlay);
const actualHighlightId = Array.isArray(highlightResult) ? highlightResult[0] : highlightResult;
setHighlightId(actualHighlightId as string);
logger.info('useEventMarker', 'createMarker', '事件高亮背景创建成功', {
highlightId: actualHighlightId,
});
}
logger.info('useEventMarker', 'createMarker', '事件标记创建成功', { logger.info('useEventMarker', 'createMarker', '事件标记创建成功', {
markerId: actualId, markerId: actualId,
label, label,
@@ -130,25 +144,34 @@ export const useEventMarker = (
* 移除事件标记 * 移除事件标记
*/ */
const removeMarker = useCallback(() => { const removeMarker = useCallback(() => {
if (!chart || !markerId) { if (!chart) {
return; return;
} }
try { try {
chart.removeOverlay(markerId); if (markerId) {
chart.removeOverlay(markerId);
}
if (highlightId) {
chart.removeOverlay(highlightId);
}
setMarker(null); setMarker(null);
setMarkerId(null); setMarkerId(null);
setHighlightId(null);
logger.debug('useEventMarker', 'removeMarker', '移除事件标记', { logger.debug('useEventMarker', 'removeMarker', '移除事件标记和高亮', {
markerId, markerId,
highlightId,
chartId: chart.id, chartId: chart.id,
}); });
} catch (err) { } catch (err) {
logger.error('useEventMarker', 'removeMarker', err as Error, { logger.error('useEventMarker', 'removeMarker', err as Error, {
markerId, markerId,
highlightId,
}); });
} }
}, [chart, markerId]); }, [chart, markerId, highlightId]);
/** /**
* 移除所有标记 * 移除所有标记
@@ -162,8 +185,9 @@ export const useEventMarker = (
removeAllEventMarkers(chart); removeAllEventMarkers(chart);
setMarker(null); setMarker(null);
setMarkerId(null); setMarkerId(null);
setHighlightId(null);
logger.debug('useEventMarker', 'removeAllMarkers', '移除所有事件标记', { logger.debug('useEventMarker', 'removeAllMarkers', '移除所有事件标记和高亮', {
chartId: chart.id, chartId: chart.id,
}); });
} catch (err) { } catch (err) {
@@ -189,15 +213,20 @@ export const useEventMarker = (
// 清理:组件卸载时移除所有标记 // 清理:组件卸载时移除所有标记
useEffect(() => { useEffect(() => {
return () => { return () => {
if (chart && markerId) { if (chart) {
try { try {
chart.removeOverlay(markerId); if (markerId) {
chart.removeOverlay(markerId);
}
if (highlightId) {
chart.removeOverlay(highlightId);
}
} catch (err) { } catch (err) {
// 忽略清理时的错误 // 忽略清理时的错误
} }
} }
}; };
}, [chart, markerId]); }, [chart, markerId, highlightId]);
return { return {
marker, marker,

View File

@@ -5,12 +5,13 @@
*/ */
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { init, dispose } from 'klinecharts'; import { init, dispose, registerIndicator } from 'klinecharts';
import type { Chart } from 'klinecharts'; import type { Chart } from 'klinecharts';
import { useColorMode } from '@chakra-ui/react'; import { useColorMode } from '@chakra-ui/react';
import { getTheme } from '../config/klineTheme'; import { getTheme, getTimelineTheme } from '../config/klineTheme';
import { CHART_INIT_OPTIONS } from '../config'; import { CHART_INIT_OPTIONS } from '../config';
import { logger } from '@utils/logger'; import { logger } from '@utils/logger';
import { avgPriceIndicator } from '../indicators/avgPriceIndicator';
export interface UseKLineChartOptions { export interface UseKLineChartOptions {
/** 图表容器 ID */ /** 图表容器 ID */
@@ -19,6 +20,8 @@ export interface UseKLineChartOptions {
height?: number; height?: number;
/** 是否自动调整大小 */ /** 是否自动调整大小 */
autoResize?: boolean; autoResize?: boolean;
/** 图表类型timeline/daily */
chartType?: 'timeline' | 'daily';
} }
export interface UseKLineChartReturn { export interface UseKLineChartReturn {
@@ -48,57 +51,122 @@ export interface UseKLineChartReturn {
export const useKLineChart = ( export const useKLineChart = (
options: UseKLineChartOptions options: UseKLineChartOptions
): UseKLineChartReturn => { ): UseKLineChartReturn => {
const { containerId, height = 400, autoResize = true } = options; const { containerId, height = 400, autoResize = true, chartType = 'daily' } = options;
const chartRef = useRef<HTMLDivElement>(null); const chartRef = useRef<HTMLDivElement>(null);
const chartInstanceRef = useRef<Chart | null>(null); const chartInstanceRef = useRef<Chart | null>(null);
const [chartInstance, setChartInstance] = useState<Chart | null>(null); // ✅ 新增chart state触发重渲染
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
// 图表初始化 // 全局注册自定义均价线指标(只执行一次)
useEffect(() => { useEffect(() => {
if (!chartRef.current) {
logger.warn('useKLineChart', 'init', '图表容器未挂载', { containerId });
return;
}
try { try {
logger.debug('useKLineChart', 'init', '开始初始化图表', { registerIndicator(avgPriceIndicator);
containerId, logger.debug('useKLineChart', '✅ 自定义均价线指标(AVG)注册成功');
height, } catch (err) {
colorMode, // 如果已注册会报错,忽略即可
}); logger.debug('useKLineChart', 'AVG指标已注册或注册失败', err);
}
}, []);
// 初始化图表实例KLineChart 10.0 API // 图表初始化(添加延迟重试机制,处理 Modal 动画延迟
const chartInstance = init(chartRef.current, { useEffect(() => {
...CHART_INIT_OPTIONS, // 图表初始化函数
// 设置初始样式(根据主题) const initChart = (): boolean => {
styles: getTheme(colorMode), if (!chartRef.current) {
}); logger.warn('useKLineChart', 'init', '图表容器未挂载,将在 50ms 后重试', { containerId });
return false;
if (!chartInstance) {
throw new Error('图表初始化失败:返回 null');
} }
chartInstanceRef.current = chartInstance; try {
setIsInitialized(true); logger.debug('useKLineChart', 'init', '开始初始化图表', {
setError(null); containerId,
height,
colorMode,
});
logger.info('useKLineChart', 'init', '图表初始化成功', { // 初始化图表实例KLineChart 10.0 API
containerId, // ✅ 根据 chartType 选择主题
chartId: chartInstance.id, const themeStyles = chartType === 'timeline'
}); ? getTimelineTheme(colorMode)
} catch (err) { : getTheme(colorMode);
const error = err as Error;
logger.error('useKLineChart', 'init', error, { containerId }); const chartInstance = init(chartRef.current, {
setError(error); ...CHART_INIT_OPTIONS,
setIsInitialized(false); // 设置初始样式(根据主题和图表类型)
styles: themeStyles,
});
if (!chartInstance) {
throw new Error('图表初始化失败:返回 null');
}
chartInstanceRef.current = chartInstance;
setChartInstance(chartInstance); // ✅ 新增:更新 state触发重渲染
setIsInitialized(true);
setError(null);
// ✅ 新增:创建成交量指标窗格
try {
const volumePaneId = chartInstance.createIndicator('VOL', false, {
height: 100, // 固定高度 100px约占整体的 20-25%
});
logger.debug('useKLineChart', 'init', '成交量窗格创建成功', {
volumePaneId,
});
} catch (err) {
logger.warn('useKLineChart', 'init', '成交量窗格创建失败', {
error: err,
});
// 不阻塞主流程,继续执行
}
logger.info('useKLineChart', 'init', '✅ 图表初始化成功', {
containerId,
chartId: chartInstance.id,
});
return true;
} catch (err) {
const error = err as Error;
logger.error('useKLineChart', 'init', error, { containerId });
setError(error);
setIsInitialized(false);
return false;
}
};
// 立即尝试初始化
if (initChart()) {
// 成功,直接返回清理函数
return () => {
if (chartInstanceRef.current) {
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
containerId,
chartId: chartInstanceRef.current.id,
});
dispose(chartInstanceRef.current);
chartInstanceRef.current = null;
setChartInstance(null); // ✅ 新增:清空 state
setIsInitialized(false);
}
};
} }
// 清理函数:销毁图表实例 // 失败则延迟重试(处理 Modal 动画延迟导致的 DOM 未挂载)
const timer = setTimeout(() => {
logger.debug('useKLineChart', 'init', '执行延迟重试', { containerId });
initChart();
}, 50);
// 清理函数:清除定时器和销毁图表实例
return () => { return () => {
clearTimeout(timer);
if (chartInstanceRef.current) { if (chartInstanceRef.current) {
logger.debug('useKLineChart', 'dispose', '销毁图表实例', { logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
containerId, containerId,
@@ -107,11 +175,12 @@ export const useKLineChart = (
dispose(chartInstanceRef.current); dispose(chartInstanceRef.current);
chartInstanceRef.current = null; chartInstanceRef.current = null;
setChartInstance(null); // ✅ 新增:清空 state
setIsInitialized(false); setIsInitialized(false);
} }
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [containerId]); // 只在 containerId 变化时重新初始化 }, [containerId, chartType]); // containerId 或 chartType 变化时重新初始化
// 主题切换:更新图表样式 // 主题切换:更新图表样式
useEffect(() => { useEffect(() => {
@@ -120,17 +189,21 @@ export const useKLineChart = (
} }
try { try {
const newTheme = getTheme(colorMode); // ✅ 根据 chartType 选择主题
const newTheme = chartType === 'timeline'
? getTimelineTheme(colorMode)
: getTheme(colorMode);
chartInstanceRef.current.setStyles(newTheme); chartInstanceRef.current.setStyles(newTheme);
logger.debug('useKLineChart', 'updateTheme', '更新图表主题', { logger.debug('useKLineChart', 'updateTheme', '更新图表主题', {
colorMode, colorMode,
chartType,
chartId: chartInstanceRef.current.id, chartId: chartInstanceRef.current.id,
}); });
} catch (err) { } catch (err) {
logger.error('useKLineChart', 'updateTheme', err as Error, { colorMode }); logger.error('useKLineChart', 'updateTheme', err as Error, { colorMode, chartType });
} }
}, [colorMode, isInitialized]); }, [colorMode, chartType, isInitialized]);
// 容器尺寸变化:调整图表大小 // 容器尺寸变化:调整图表大小
useEffect(() => { useEffect(() => {
@@ -165,7 +238,7 @@ export const useKLineChart = (
}, [isInitialized, autoResize]); }, [isInitialized, autoResize]);
return { return {
chart: chartInstanceRef.current, chart: chartInstance, // ✅ 返回 state 而非 ref确保变化触发重渲染
chartRef, chartRef,
isInitialized, isInitialized,
error, error,

View File

@@ -9,7 +9,8 @@ import type { Chart } from 'klinecharts';
import type { ChartType, KLineDataPoint, RawDataPoint } from '../types'; import type { ChartType, KLineDataPoint, RawDataPoint } from '../types';
import { processChartData } from '../utils/dataAdapter'; import { processChartData } from '../utils/dataAdapter';
import { logger } from '@utils/logger'; import { logger } from '@utils/logger';
import { stockService } from '@services/stockService'; import { stockService } from '@services/eventService';
import { klineDataCache, getCacheKey } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
export interface UseKLineDataOptions { export interface UseKLineDataOptions {
/** KLineChart 实例 */ /** KLineChart 实例 */
@@ -91,22 +92,38 @@ export const useKLineData = (
eventTime, eventTime,
}); });
// 调用后端 API 获取数据 // 1. 先检查缓存
const response = await stockService.getKlineData( const cacheKey = getCacheKey(stockCode, eventTime, chartType);
stockCode, const cachedData = klineDataCache.get(cacheKey);
chartType,
eventTime
);
if (!response || !response.data) { let rawDataList;
throw new Error('后端返回数据为空');
if (cachedData && cachedData.length > 0) {
// 使用缓存数据
rawDataList = cachedData;
} else {
// 2. 缓存没有数据,调用 API 请求
const response = await stockService.getKlineData(
stockCode,
chartType,
eventTime
);
if (!response || !response.data) {
throw new Error('后端返回数据为空');
}
rawDataList = response.data;
// 3. 将数据写入缓存(避免下次重复请求)
klineDataCache.set(cacheKey, rawDataList);
} }
const rawDataList = response.data;
setRawData(rawDataList); setRawData(rawDataList);
// 数据转换和处理 // 数据转换和处理
const processedData = processChartData(rawDataList, chartType, eventTime); const processedData = processChartData(rawDataList, chartType, eventTime);
setData(processedData); setData(processedData);
logger.info('useKLineData', 'loadData', '数据加载成功', { logger.info('useKLineData', 'loadData', '数据加载成功', {
@@ -130,7 +147,7 @@ export const useKLineData = (
}, [stockCode, chartType, eventTime]); }, [stockCode, chartType, eventTime]);
/** /**
* 更新图表数据(使用 DataLoader 模式 * 更新图表数据(使用 setDataLoader 方法
*/ */
const updateChartData = useCallback( const updateChartData = useCallback(
(klineData: KLineDataPoint[]) => { (klineData: KLineDataPoint[]) => {
@@ -139,29 +156,120 @@ export const useKLineData = (
} }
try { try {
// KLineChart 10.0: 使用 setDataLoader 方法 // 步骤 1: 设置 symbol必需getBars 调用的前置条件)
chart.setDataLoader({ (chart as any).setSymbol({
getBars: (params) => { ticker: stockCode || 'UNKNOWN', // 股票代码
// 将数据传递给图表 pricePrecision: 2, // 价格精度2位小数
params.callback(klineData, { more: false }); volumePrecision: 0 // 成交量精度(整数)
});
logger.debug('useKLineData', 'updateChartData', 'DataLoader 回调', { // 步骤 2: 设置 period必需getBars 调用的前置条件)
dataCount: klineData.length, const periodType = chartType === 'timeline' ? 'minute' : 'day';
(chart as any).setPeriod({
type: periodType, // 分时图=minute日K=day
span: 1 // 周期跨度1分钟/1天
});
// 步骤 3: 设置 DataLoader同步数据加载器
(chart as any).setDataLoader({
getBars: (params: any) => {
if (params.type === 'init') {
// 初始化加载:返回完整数据
params.callback(klineData, false); // false = 无更多数据可加载
} else if (params.type === 'forward' || params.type === 'backward') {
// 向前/向后加载:我们没有更多数据,返回空数组
params.callback([], false);
}
}
});
// 步骤 4: 触发初始化加载(这会调用 getBars with type="init"
(chart as any).resetData();
// 步骤 5: 根据数据量调整可见范围和柱子间距(让 K 线柱子填满图表区域)
setTimeout(() => {
try {
const dataLength = klineData.length;
if (dataLength > 0) {
// 获取图表容器宽度
const chartDom = (chart as any).getDom();
const chartWidth = chartDom?.clientWidth || 1200;
// 计算最优柱子间距
// 公式barSpace = (图表宽度 / 数据数量) * 0.7
// 0.7 是为了留出一些间距,让图表不会太拥挤
const optimalBarSpace = Math.max(8, Math.min(50, (chartWidth / dataLength) * 0.7));
(chart as any).setBarSpace(optimalBarSpace);
// 减少右侧空白(默认值可能是 100-200调小会减少右侧空白
(chart as any).setOffsetRightDistance(50);
}
} catch (err) {
logger.error('useKLineData', 'updateChartData', err as Error, {
step: '调整可见范围失败',
}); });
}, }
}); }, 100); // 延迟 100ms 确保数据已加载和渲染
logger.debug('useKLineData', 'updateChartData', '图表数据已更新', { // ✅ 步骤 4: 分时图添加均价线(使用自定义 AVG 指标)
dataCount: klineData.length, if (chartType === 'timeline' && klineData.length > 0) {
chartId: chart.id, setTimeout(() => {
}); try {
// 在主图窗格创建 AVG 均价线指标
(chart as any).createIndicator('AVG', true, {
id: 'candle_pane', // 主图窗格
});
console.log('[DEBUG] ✅ 均价线AVG指标添加成功');
} catch (err) {
console.error('[DEBUG] ❌ 均价线添加失败:', err);
}
}, 150); // 延迟 150ms确保数据加载完成后再创建指标
// ✅ 步骤 5: 添加昨收价基准线(灰色虚线)
setTimeout(() => {
try {
const prevClose = klineData[0]?.prev_close;
if (prevClose && prevClose > 0) {
// 创建水平线覆盖层
(chart as any).createOverlay({
name: 'horizontalStraightLine',
id: 'prev_close_line',
points: [{ value: prevClose }],
styles: {
line: {
style: 'dashed',
dashValue: [4, 2],
size: 1,
color: '#888888', // 灰色虚线
},
},
extendData: {
label: `昨收: ${prevClose.toFixed(2)}`,
},
});
console.log('[DEBUG] ✅ 昨收价基准线添加成功:', prevClose);
}
} catch (err) {
console.error('[DEBUG] ❌ 昨收价基准线添加失败:', err);
}
}, 200); // 延迟 200ms确保均价线创建完成后再添加
}
logger.debug(
'useKLineData',
`updateChartData - ${stockCode} (${chartType}) - ${klineData.length}条数据加载成功`
);
} catch (err) { } catch (err) {
logger.error('useKLineData', 'updateChartData', err as Error, { logger.error('useKLineData', 'updateChartData', err as Error, {
dataCount: klineData.length, dataCount: klineData.length,
}); });
} }
}, },
[chart] [chart, stockCode, chartType]
); );
/** /**
@@ -172,9 +280,10 @@ export const useKLineData = (
setData(newData); setData(newData);
updateChartData(newData); updateChartData(newData);
logger.debug('useKLineData', 'updateData', '手动更新数据', { logger.debug(
newDataCount: newData.length, 'useKLineData',
}); `updateData - ${stockCode} (${chartType}) - ${newData.length}条数据手动更新`
);
}, },
[updateChartData] [updateChartData]
); );
@@ -189,9 +298,7 @@ export const useKLineData = (
if (chart) { if (chart) {
chart.resetData(); chart.resetData();
logger.debug('useKLineData', 'clearData', '清空数据', { logger.debug('useKLineData', `clearData - chartId: ${(chart as any).id}`);
chartId: chart.id,
});
} }
}, [chart]); }, [chart]);

View File

@@ -0,0 +1,93 @@
/**
* 自定义均价线指标
*
* 用于分时图显示橙黄色均价线
* 计算公式:累计成交额 / 累计成交量
*/
import type { Indicator, KLineData } from 'klinecharts';
export const avgPriceIndicator: Indicator = {
name: 'AVG',
shortName: 'AVG',
calcParams: [],
shouldOhlc: false, // 不显示 OHLC 信息
shouldFormatBigNumber: false,
precision: 2,
minValue: null,
maxValue: null,
figures: [
{
key: 'avg',
title: '均价: ',
type: 'line',
},
],
/**
* 计算均价
* @param dataList K线数据列表
* @returns 均价数据
*/
calc: (dataList: KLineData[]) => {
let totalAmount = 0; // 累计成交额
let totalVolume = 0; // 累计成交量
return dataList.map((kLineData) => {
const { close = 0, volume = 0 } = kLineData;
totalAmount += close * volume;
totalVolume += volume;
const avgPrice = totalVolume > 0 ? totalAmount / totalVolume : close;
return { avg: avgPrice };
});
},
/**
* 绘制样式配置
*/
styles: {
lines: [
{
color: '#FF9800', // 橙黄色
size: 2,
style: 'solid',
smooth: true,
},
],
},
/**
* Tooltip 格式化(显示均价 + 涨跌幅)
*/
createTooltipDataSource: ({ kLineData, indicator, defaultStyles }: any) => {
if (!indicator?.avg) {
return {
title: { text: '均价', color: defaultStyles.tooltip.text.color },
value: { text: '--', color: '#FF9800' },
};
}
const avgPrice = indicator.avg;
const prevClose = kLineData?.prev_close;
// 计算均价涨跌幅
let changeText = `¥${avgPrice.toFixed(2)}`;
if (prevClose && prevClose > 0) {
const changePercent = ((avgPrice - prevClose) / prevClose * 100).toFixed(2);
const changeValue = (avgPrice - prevClose).toFixed(2);
changeText = `¥${avgPrice.toFixed(2)} (${changeValue}, ${changePercent}%)`;
}
return {
title: { text: '均价', color: defaultStyles.tooltip.text.color },
value: {
text: changeText,
color: '#FF9800',
},
};
},
};

View File

@@ -25,6 +25,8 @@ export interface KLineDataPoint {
volume: number; volume: number;
/** 成交额(可选) */ /** 成交额(可选) */
turnover?: number; turnover?: number;
/** 昨收价(用于百分比计算和基准线)- 分时图专用 */
prev_close?: number;
} }
/** /**
@@ -51,6 +53,8 @@ export interface RawDataPoint {
volume: number; volume: number;
/** 均价(分时图专用) */ /** 均价(分时图专用) */
avg_price?: number; avg_price?: number;
/** 昨收价(用于百分比计算和基准线)- 分时图专用 */
prev_close?: number;
} }
/** /**

View File

@@ -38,6 +38,7 @@ export const convertToKLineData = (
close: Number(item.close) || 0, close: Number(item.close) || 0,
volume: Number(item.volume) || 0, volume: Number(item.volume) || 0,
turnover: item.turnover ? Number(item.turnover) : undefined, turnover: item.turnover ? Number(item.turnover) : undefined,
prev_close: item.prev_close ? Number(item.prev_close) : undefined, // ✅ 新增:昨收价(用于百分比计算和基准线)
}; };
}); });
} catch (error) { } catch (error) {
@@ -171,10 +172,66 @@ export const deduplicateData = (data: KLineDataPoint[]): KLineDataPoint[] => {
return Array.from(map.values()); return Array.from(map.values());
}; };
/**
* 根据事件时间裁剪数据范围前后2周
*
* @param data K线数据
* @param eventTime 事件时间ISO字符串
* @param chartType 图表类型
* @returns KLineDataPoint[] 裁剪后的数据
*/
export const trimDataByEventTime = (
data: KLineDataPoint[],
eventTime: string,
chartType: ChartType
): KLineDataPoint[] => {
if (!eventTime || !data || data.length === 0) {
return data;
}
try {
const eventTimestamp = dayjs(eventTime).valueOf();
// 根据图表类型设置不同的时间范围
let beforeDays: number;
let afterDays: number;
if (chartType === 'timeline') {
// 分时图只显示事件当天前后0天
beforeDays = 0;
afterDays = 0;
} else {
// 日K线显示前后14天2周
beforeDays = 14;
afterDays = 14;
}
const startTime = dayjs(eventTime).subtract(beforeDays, 'day').startOf('day').valueOf();
const endTime = dayjs(eventTime).add(afterDays, 'day').endOf('day').valueOf();
const trimmedData = data.filter((item) => {
return item.timestamp >= startTime && item.timestamp <= endTime;
});
logger.debug('dataAdapter', 'trimDataByEventTime', '数据时间范围裁剪完成', {
originalLength: data.length,
trimmedLength: trimmedData.length,
eventTime,
chartType,
dateRange: `${dayjs(startTime).format('YYYY-MM-DD')} ~ ${dayjs(endTime).format('YYYY-MM-DD')}`,
});
return trimmedData;
} catch (error) {
logger.error('dataAdapter', 'trimDataByEventTime', error as Error, { eventTime });
return data; // 出错时返回原始数据
}
};
/** /**
* 完整的数据处理流程 * 完整的数据处理流程
* *
* 转换 → 验证 → 去重 → 排序 * 转换 → 验证 → 去重 → 排序 → 时间裁剪(如果有 eventTime
* *
* @param rawData 后端原始数据 * @param rawData 后端原始数据
* @param chartType 图表类型 * @param chartType 图表类型
@@ -198,10 +255,16 @@ export const processChartData = (
// 4. 排序 // 4. 排序
data = sortDataByTime(data); data = sortDataByTime(data);
// 5. 根据事件时间裁剪范围(如果提供了 eventTime
if (eventTime) {
data = trimDataByEventTime(data, eventTime, chartType);
}
logger.debug('dataAdapter', 'processChartData', '数据处理完成', { logger.debug('dataAdapter', 'processChartData', '数据处理完成', {
rawLength: rawData.length, rawLength: rawData.length,
processedLength: data.length, processedLength: data.length,
chartType, chartType,
hasEventTime: !!eventTime,
}); });
return data; return data;

View File

@@ -92,6 +92,61 @@ export const createEventMarkerOverlay = (
} }
}; };
/**
* 创建事件日K线黄色高亮覆盖层垂直矩形背景
*
* @param eventTime 事件时间ISO字符串
* @param data K线数据
* @returns OverlayCreate | null 高亮覆盖层配置
*/
export const createEventHighlightOverlay = (
eventTime: string,
data: KLineDataPoint[]
): OverlayCreate | null => {
try {
const eventTimestamp = dayjs(eventTime).valueOf();
const closestPoint = findClosestDataPoint(data, eventTimestamp);
if (!closestPoint) {
logger.warn('eventMarkerUtils', 'createEventHighlightOverlay', '未找到匹配的数据点');
return null;
}
// 创建垂直矩形覆盖层(从图表顶部到底部的黄色半透明背景)
const overlay: OverlayCreate = {
name: 'rect', // 矩形覆盖层
id: `event-highlight-${eventTimestamp}`,
points: [
{
timestamp: closestPoint.timestamp,
value: closestPoint.high * 1.05, // 顶部位置高于最高价5%
},
{
timestamp: closestPoint.timestamp,
value: closestPoint.low * 0.95, // 底部位置低于最低价5%
},
],
styles: {
style: 'fill',
color: 'rgba(255, 193, 7, 0.15)', // 黄色半透明背景15%透明度)
borderColor: '#FFD54F', // 黄色边框
borderSize: 2,
borderStyle: 'solid',
},
};
logger.debug('eventMarkerUtils', 'createEventHighlightOverlay', '创建事件高亮覆盖层', {
timestamp: closestPoint.timestamp,
eventTime,
});
return overlay;
} catch (error) {
logger.error('eventMarkerUtils', 'createEventHighlightOverlay', error as Error);
return null;
}
};
/** /**
* 计算标记的 Y 轴位置 * 计算标记的 Y 轴位置
* *

View File

@@ -78,10 +78,16 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) {
const volume = Math.floor(Math.random() * 500000000 + 100000000); const volume = Math.floor(Math.random() * 500000000 + 100000000);
// ✅ 修复:为分时图添加完整的 OHLC 字段
const closePrice = parseFloat(price.toFixed(2));
data.push({ data.push({
time: formatTime(current), time: formatTime(current),
price: parseFloat(price.toFixed(2)), timestamp: current.getTime(), // ✅ 新增:毫秒时间戳
close: parseFloat(price.toFixed(2)), open: parseFloat((price * 0.9999).toFixed(2)), // ✅ 新增:开盘价(略低于收盘)
high: parseFloat((price * 1.0002).toFixed(2)), // ✅ 新增:最高价(略高于收盘)
low: parseFloat((price * 0.9997).toFixed(2)), // ✅ 新增:最低价(略低于收盘)
close: closePrice, // ✅ 保留:收盘价
price: closePrice, // ✅ 保留:兼容字段(供 MiniTimelineChart 使用)
volume: volume, volume: volume,
prev_close: basePrice prev_close: basePrice
}); });