Merge branch 'feature_2025/251117_pref' of https://git.valuefrontier.cn/vf/vf_react into feature_2025/251117_pref
This commit is contained in:
287
src/components/StockChart/StockChartKLineModal.tsx
Normal file
287
src/components/StockChart/StockChartKLineModal.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* StockChartKLineModal - K 线图表模态框组件
|
||||
*
|
||||
* 使用 KLineChart 库实现的专业金融图表组件
|
||||
* 替换原有的 ECharts 实现(StockChartAntdModal.js)
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Modal, Button, Radio, Select, Space, Spin, Alert } from 'antd';
|
||||
import type { RadioChangeEvent } from 'antd';
|
||||
import {
|
||||
LineChartOutlined,
|
||||
BarChartOutlined,
|
||||
SettingOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
// 自定义 Hooks
|
||||
import { useKLineChart, useKLineData, useEventMarker } from './hooks';
|
||||
|
||||
// 类型定义
|
||||
import type { ChartType, StockInfo } from './types';
|
||||
|
||||
// 配置常量
|
||||
import {
|
||||
CHART_TYPE_CONFIG,
|
||||
CHART_HEIGHTS,
|
||||
INDICATORS,
|
||||
DEFAULT_SUB_INDICATORS,
|
||||
} from './config';
|
||||
|
||||
// 工具函数
|
||||
import { createSubIndicators } from './utils';
|
||||
|
||||
// 日志
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
// ==================== 组件 Props ====================
|
||||
|
||||
export interface StockChartKLineModalProps {
|
||||
/** 是否显示模态框 */
|
||||
visible: boolean;
|
||||
/** 关闭模态框回调 */
|
||||
onClose: () => void;
|
||||
/** 股票信息 */
|
||||
stock: StockInfo;
|
||||
/** 事件时间(ISO 字符串,可选) */
|
||||
eventTime?: string;
|
||||
/** 事件标题(用于标记标签,可选) */
|
||||
eventTitle?: string;
|
||||
}
|
||||
|
||||
// ==================== 主组件 ====================
|
||||
|
||||
const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
stock,
|
||||
eventTime,
|
||||
eventTitle,
|
||||
}) => {
|
||||
// ==================== 状态管理 ====================
|
||||
|
||||
/** 图表类型(分时图/日K线) */
|
||||
const [chartType, setChartType] = useState<ChartType>('daily');
|
||||
|
||||
/** 选中的副图指标 */
|
||||
const [selectedIndicators, setSelectedIndicators] = useState<string[]>(
|
||||
DEFAULT_SUB_INDICATORS
|
||||
);
|
||||
|
||||
// ==================== 自定义 Hooks ====================
|
||||
|
||||
/** 图表实例管理 */
|
||||
const { chart, chartRef, isInitialized, error: chartError } = useKLineChart({
|
||||
containerId: `kline-chart-${stock.stock_code}`,
|
||||
height: CHART_HEIGHTS.main,
|
||||
autoResize: true,
|
||||
});
|
||||
|
||||
/** 数据加载管理 */
|
||||
const {
|
||||
data,
|
||||
loading: dataLoading,
|
||||
error: dataError,
|
||||
loadData,
|
||||
} = useKLineData({
|
||||
chart,
|
||||
stockCode: stock.stock_code,
|
||||
chartType,
|
||||
eventTime,
|
||||
autoLoad: visible, // 模态框打开时自动加载
|
||||
});
|
||||
|
||||
/** 事件标记管理 */
|
||||
const { marker } = useEventMarker({
|
||||
chart,
|
||||
data,
|
||||
eventTime,
|
||||
eventTitle,
|
||||
autoCreate: true,
|
||||
});
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
|
||||
/**
|
||||
* 切换图表类型(分时图 ↔ 日K线)
|
||||
*/
|
||||
const handleChartTypeChange = useCallback((e: RadioChangeEvent) => {
|
||||
const newType = e.target.value as ChartType;
|
||||
setChartType(newType);
|
||||
|
||||
logger.debug('StockChartKLineModal', 'handleChartTypeChange', '切换图表类型', {
|
||||
newType,
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 切换副图指标
|
||||
*/
|
||||
const handleIndicatorChange = useCallback(
|
||||
(values: string[]) => {
|
||||
setSelectedIndicators(values);
|
||||
|
||||
if (!chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先移除所有副图指标(KLineChart 会自动移除)
|
||||
// 然后创建新的指标
|
||||
createSubIndicators(chart, values);
|
||||
|
||||
logger.debug('StockChartKLineModal', 'handleIndicatorChange', '切换副图指标', {
|
||||
indicators: values,
|
||||
});
|
||||
},
|
||||
[chart]
|
||||
);
|
||||
|
||||
/**
|
||||
* 刷新数据
|
||||
*/
|
||||
const handleRefresh = useCallback(() => {
|
||||
loadData();
|
||||
logger.debug('StockChartKLineModal', 'handleRefresh', '刷新数据');
|
||||
}, [loadData]);
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
/** 是否有错误 */
|
||||
const hasError = useMemo(() => {
|
||||
return !!chartError || !!dataError;
|
||||
}, [chartError, dataError]);
|
||||
|
||||
/** 错误消息 */
|
||||
const errorMessage = useMemo(() => {
|
||||
if (chartError) {
|
||||
return `图表初始化失败: ${chartError.message}`;
|
||||
}
|
||||
if (dataError) {
|
||||
return `数据加载失败: ${dataError.message}`;
|
||||
}
|
||||
return null;
|
||||
}, [chartError, dataError]);
|
||||
|
||||
/** 模态框标题 */
|
||||
const modalTitle = useMemo(() => {
|
||||
return `${stock.stock_name}(${stock.stock_code}) - ${CHART_TYPE_CONFIG[chartType].label}`;
|
||||
}, [stock, chartType]);
|
||||
|
||||
/** 是否显示加载状态 */
|
||||
const showLoading = useMemo(() => {
|
||||
return dataLoading || !isInitialized;
|
||||
}, [dataLoading, isInitialized]);
|
||||
|
||||
// ==================== 副作用 ====================
|
||||
|
||||
// 无副作用,都在 Hooks 中管理
|
||||
|
||||
// ==================== 渲染 ====================
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={modalTitle}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={1200}
|
||||
footer={null}
|
||||
centered
|
||||
destroyOnClose // 关闭时销毁组件(释放图表资源)
|
||||
>
|
||||
{/* 工具栏 */}
|
||||
<Box mb={4}>
|
||||
<Space wrap>
|
||||
{/* 图表类型切换 */}
|
||||
<Radio.Group value={chartType} onChange={handleChartTypeChange}>
|
||||
<Radio.Button value="timeline">
|
||||
<LineChartOutlined /> 分时图
|
||||
</Radio.Button>
|
||||
<Radio.Button value="daily">
|
||||
<BarChartOutlined /> 日K线
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
{/* 副图指标选择 */}
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择副图指标"
|
||||
value={selectedIndicators}
|
||||
onChange={handleIndicatorChange}
|
||||
style={{ minWidth: 200 }}
|
||||
maxTagCount={2}
|
||||
>
|
||||
{INDICATORS.sub.map((indicator) => (
|
||||
<Select.Option key={indicator.name} value={indicator.name}>
|
||||
<SettingOutlined /> {indicator.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* 刷新按钮 */}
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={dataLoading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</Box>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{hasError && (
|
||||
<Alert
|
||||
message="加载失败"
|
||||
description={errorMessage}
|
||||
type="error"
|
||||
closable
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图表容器 */}
|
||||
<Box position="relative">
|
||||
{/* 加载遮罩 */}
|
||||
{showLoading && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
zIndex={10}
|
||||
>
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* KLineChart 容器 */}
|
||||
<div
|
||||
ref={chartRef}
|
||||
id={`kline-chart-${stock.stock_code}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: `${CHART_HEIGHTS.main}px`,
|
||||
opacity: showLoading ? 0.5 : 1,
|
||||
transition: 'opacity 0.3s',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 数据信息(调试用,生产环境可移除) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Box mt={2} fontSize="12px" color="gray.500">
|
||||
<Space split="|">
|
||||
<span>数据点数: {data.length}</span>
|
||||
<span>事件标记: {marker ? marker.label : '无'}</span>
|
||||
<span>图表ID: {chart?.id || '未初始化'}</span>
|
||||
</Space>
|
||||
</Box>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockChartKLineModal;
|
||||
205
src/components/StockChart/config/chartConfig.ts
Normal file
205
src/components/StockChart/config/chartConfig.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* KLineChart 图表常量配置
|
||||
*
|
||||
* 包含图表默认配置、技术指标列表、事件标记配置等
|
||||
*/
|
||||
|
||||
import type { ChartConfig, ChartType } from '../types';
|
||||
|
||||
/**
|
||||
* 图表默认高度(px)
|
||||
*/
|
||||
export const CHART_HEIGHTS = {
|
||||
/** 主图高度 */
|
||||
main: 400,
|
||||
/** 副图高度(技术指标) */
|
||||
sub: 150,
|
||||
/** 移动端主图高度 */
|
||||
mainMobile: 300,
|
||||
/** 移动端副图高度 */
|
||||
subMobile: 100,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 技术指标配置
|
||||
*/
|
||||
export const INDICATORS = {
|
||||
/** 主图指标(叠加在 K 线图上) */
|
||||
main: [
|
||||
{
|
||||
name: 'MA',
|
||||
label: '均线',
|
||||
params: [5, 10, 20, 30],
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A'],
|
||||
},
|
||||
{
|
||||
name: 'EMA',
|
||||
label: '指数移动平均',
|
||||
params: [5, 10, 20, 30],
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A'],
|
||||
},
|
||||
{
|
||||
name: 'BOLL',
|
||||
label: '布林带',
|
||||
params: [20, 2],
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
|
||||
},
|
||||
],
|
||||
|
||||
/** 副图指标(单独窗口显示) */
|
||||
sub: [
|
||||
{
|
||||
name: 'VOL',
|
||||
label: '成交量',
|
||||
params: [5, 10, 20],
|
||||
colors: ['#ef5350', '#26a69a'],
|
||||
},
|
||||
{
|
||||
name: 'MACD',
|
||||
label: 'MACD',
|
||||
params: [12, 26, 9],
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
|
||||
},
|
||||
{
|
||||
name: 'KDJ',
|
||||
label: 'KDJ',
|
||||
params: [9, 3, 3],
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
|
||||
},
|
||||
{
|
||||
name: 'RSI',
|
||||
label: 'RSI',
|
||||
params: [6, 12, 24],
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 默认主图指标(初始显示)
|
||||
*/
|
||||
export const DEFAULT_MAIN_INDICATOR = 'MA';
|
||||
|
||||
/**
|
||||
* 默认副图指标(初始显示)
|
||||
*/
|
||||
export const DEFAULT_SUB_INDICATORS = ['VOL', 'MACD'];
|
||||
|
||||
/**
|
||||
* 图表类型配置
|
||||
*/
|
||||
export const CHART_TYPE_CONFIG: Record<ChartType, { label: string; dateFormat: string }> = {
|
||||
timeline: {
|
||||
label: '分时图',
|
||||
dateFormat: 'HH:mm', // 时间格式:09:30
|
||||
},
|
||||
daily: {
|
||||
label: '日K线',
|
||||
dateFormat: 'YYYY-MM-DD', // 日期格式:2024-01-01
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 事件标记配置
|
||||
*/
|
||||
export const EVENT_MARKER_CONFIG = {
|
||||
/** 默认颜色 */
|
||||
defaultColor: '#ff9800',
|
||||
/** 默认位置 */
|
||||
defaultPosition: 'top' as const,
|
||||
/** 默认图标 */
|
||||
defaultIcon: '📌',
|
||||
/** 标记大小 */
|
||||
size: {
|
||||
point: 8, // 标记点半径
|
||||
icon: 20, // 图标大小
|
||||
},
|
||||
/** 文本配置 */
|
||||
text: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'Helvetica, Arial, sans-serif',
|
||||
color: '#ffffff',
|
||||
padding: 4,
|
||||
borderRadius: 4,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 数据加载配置
|
||||
*/
|
||||
export const DATA_LOADER_CONFIG = {
|
||||
/** 最大数据点数(避免性能问题) */
|
||||
maxDataPoints: 1000,
|
||||
/** 初始加载数据点数 */
|
||||
initialLoadCount: 100,
|
||||
/** 加载更多时的数据点数 */
|
||||
loadMoreCount: 50,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 缩放配置
|
||||
*/
|
||||
export const ZOOM_CONFIG = {
|
||||
/** 最小缩放比例(显示更多 K 线) */
|
||||
minZoom: 0.5,
|
||||
/** 最大缩放比例(显示更少 K 线) */
|
||||
maxZoom: 2.0,
|
||||
/** 默认缩放比例 */
|
||||
defaultZoom: 1.0,
|
||||
/** 缩放步长 */
|
||||
zoomStep: 0.1,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 默认图表配置
|
||||
*/
|
||||
export const DEFAULT_CHART_CONFIG: ChartConfig = {
|
||||
type: 'daily',
|
||||
showIndicators: true,
|
||||
defaultIndicators: DEFAULT_SUB_INDICATORS,
|
||||
height: CHART_HEIGHTS.main,
|
||||
showGrid: true,
|
||||
showCrosshair: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 图表初始化选项(传递给 KLineChart.init)
|
||||
*/
|
||||
export const CHART_INIT_OPTIONS = {
|
||||
/** 时区(中国标准时间) */
|
||||
timezone: 'Asia/Shanghai',
|
||||
/** 语言 */
|
||||
locale: 'zh-CN',
|
||||
/** 自定义配置 */
|
||||
customApi: {
|
||||
formatDate: (timestamp: number, format: string) => {
|
||||
// 可在此处自定义日期格式化逻辑
|
||||
return new Date(timestamp).toLocaleString('zh-CN');
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 分时图特殊配置
|
||||
*/
|
||||
export const TIMELINE_CONFIG = {
|
||||
/** 交易时段(A 股) */
|
||||
tradingSessions: [
|
||||
{ start: '09:30', end: '11:30' }, // 上午
|
||||
{ start: '13:00', end: '15:00' }, // 下午
|
||||
],
|
||||
/** 是否显示均价线 */
|
||||
showAverageLine: true,
|
||||
/** 均价线颜色 */
|
||||
averageLineColor: '#FFB74D',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 日K线特殊配置
|
||||
*/
|
||||
export const DAILY_KLINE_CONFIG = {
|
||||
/** 最大显示天数 */
|
||||
maxDays: 250, // 约一年交易日
|
||||
/** 默认显示天数 */
|
||||
defaultDays: 60,
|
||||
} as const;
|
||||
30
src/components/StockChart/config/index.ts
Normal file
30
src/components/StockChart/config/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* StockChart 配置统一导出
|
||||
*
|
||||
* 使用方式:
|
||||
* import { lightTheme, DEFAULT_CHART_CONFIG } from '@components/StockChart/config';
|
||||
*/
|
||||
|
||||
// 主题配置
|
||||
export {
|
||||
CHART_COLORS,
|
||||
lightTheme,
|
||||
darkTheme,
|
||||
getTheme,
|
||||
} from './klineTheme';
|
||||
|
||||
// 图表配置
|
||||
export {
|
||||
CHART_HEIGHTS,
|
||||
INDICATORS,
|
||||
DEFAULT_MAIN_INDICATOR,
|
||||
DEFAULT_SUB_INDICATORS,
|
||||
CHART_TYPE_CONFIG,
|
||||
EVENT_MARKER_CONFIG,
|
||||
DATA_LOADER_CONFIG,
|
||||
ZOOM_CONFIG,
|
||||
DEFAULT_CHART_CONFIG,
|
||||
CHART_INIT_OPTIONS,
|
||||
TIMELINE_CONFIG,
|
||||
DAILY_KLINE_CONFIG,
|
||||
} from './chartConfig';
|
||||
298
src/components/StockChart/config/klineTheme.ts
Normal file
298
src/components/StockChart/config/klineTheme.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* KLineChart 主题配置
|
||||
*
|
||||
* 适配 klinecharts@10.0.0-beta1
|
||||
* 参考: https://github.com/klinecharts/KLineChart/blob/main/docs/en-US/guide/styles.md
|
||||
*/
|
||||
|
||||
import type { DeepPartial, Styles } from 'klinecharts';
|
||||
|
||||
/**
|
||||
* 图表主题颜色配置
|
||||
*/
|
||||
export const CHART_COLORS = {
|
||||
// 涨跌颜色(中国市场习惯:红涨绿跌)
|
||||
up: '#ef5350', // 上涨红色
|
||||
down: '#26a69a', // 下跌绿色
|
||||
neutral: '#888888', // 平盘灰色
|
||||
|
||||
// 主题色(继承自 Argon Dashboard)
|
||||
primary: '#1b3bbb', // Navy 500
|
||||
secondary: '#728fea', // Navy 300
|
||||
background: '#ffffff',
|
||||
backgroundDark: '#1B254B',
|
||||
|
||||
// 文本颜色
|
||||
text: '#333333',
|
||||
textSecondary: '#888888',
|
||||
textDark: '#ffffff',
|
||||
|
||||
// 网格颜色
|
||||
grid: '#e0e0e0',
|
||||
gridDark: '#2d3748',
|
||||
|
||||
// 边框颜色
|
||||
border: '#e0e0e0',
|
||||
borderDark: '#2d3748',
|
||||
|
||||
// 事件标记颜色
|
||||
eventMarker: '#ff9800',
|
||||
eventMarkerText: '#ffffff',
|
||||
};
|
||||
|
||||
/**
|
||||
* 浅色主题配置(默认)
|
||||
*/
|
||||
export const lightTheme: DeepPartial<Styles> = {
|
||||
candle: {
|
||||
type: 'candle_solid', // 实心蜡烛图
|
||||
bar: {
|
||||
upColor: CHART_COLORS.up,
|
||||
downColor: CHART_COLORS.down,
|
||||
noChangeColor: CHART_COLORS.neutral,
|
||||
},
|
||||
priceMark: {
|
||||
show: true,
|
||||
high: {
|
||||
color: CHART_COLORS.up,
|
||||
},
|
||||
low: {
|
||||
color: CHART_COLORS.down,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
showRule: 'always',
|
||||
showType: 'standard',
|
||||
labels: ['时间: ', '开: ', '收: ', '高: ', '低: ', '成交量: '],
|
||||
text: {
|
||||
size: 12,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
color: CHART_COLORS.text,
|
||||
},
|
||||
},
|
||||
},
|
||||
indicator: {
|
||||
tooltip: {
|
||||
showRule: 'always',
|
||||
showType: 'standard',
|
||||
text: {
|
||||
size: 12,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
color: CHART_COLORS.text,
|
||||
},
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
axisLine: {
|
||||
show: true,
|
||||
color: CHART_COLORS.border,
|
||||
},
|
||||
tickLine: {
|
||||
show: true,
|
||||
length: 3,
|
||||
color: CHART_COLORS.border,
|
||||
},
|
||||
tickText: {
|
||||
show: true,
|
||||
color: CHART_COLORS.textSecondary,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
axisLine: {
|
||||
show: true,
|
||||
color: CHART_COLORS.border,
|
||||
},
|
||||
tickLine: {
|
||||
show: true,
|
||||
length: 3,
|
||||
color: CHART_COLORS.border,
|
||||
},
|
||||
tickText: {
|
||||
show: true,
|
||||
color: CHART_COLORS.textSecondary,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
size: 12,
|
||||
},
|
||||
type: 'normal', // 'normal' | 'percentage' | 'log'
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
horizontal: {
|
||||
show: true,
|
||||
size: 1,
|
||||
color: CHART_COLORS.grid,
|
||||
style: 'dashed',
|
||||
},
|
||||
vertical: {
|
||||
show: false, // 垂直网格线通常关闭,避免过于密集
|
||||
},
|
||||
},
|
||||
separator: {
|
||||
size: 1,
|
||||
color: CHART_COLORS.border,
|
||||
},
|
||||
crosshair: {
|
||||
show: true,
|
||||
horizontal: {
|
||||
show: true,
|
||||
line: {
|
||||
show: true,
|
||||
style: 'dashed',
|
||||
dashValue: [4, 2],
|
||||
size: 1,
|
||||
color: CHART_COLORS.primary,
|
||||
},
|
||||
text: {
|
||||
show: true,
|
||||
color: CHART_COLORS.textDark,
|
||||
size: 12,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
backgroundColor: CHART_COLORS.primary,
|
||||
},
|
||||
},
|
||||
vertical: {
|
||||
show: true,
|
||||
line: {
|
||||
show: true,
|
||||
style: 'dashed',
|
||||
dashValue: [4, 2],
|
||||
size: 1,
|
||||
color: CHART_COLORS.primary,
|
||||
},
|
||||
text: {
|
||||
show: true,
|
||||
color: CHART_COLORS.textDark,
|
||||
size: 12,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
backgroundColor: CHART_COLORS.primary,
|
||||
},
|
||||
},
|
||||
},
|
||||
overlay: {
|
||||
// 事件标记覆盖层样式
|
||||
point: {
|
||||
color: CHART_COLORS.eventMarker,
|
||||
borderColor: CHART_COLORS.eventMarker,
|
||||
borderSize: 1,
|
||||
radius: 5,
|
||||
activeColor: CHART_COLORS.eventMarker,
|
||||
activeBorderColor: CHART_COLORS.eventMarker,
|
||||
activeBorderSize: 2,
|
||||
activeRadius: 6,
|
||||
},
|
||||
line: {
|
||||
style: 'solid',
|
||||
smooth: false,
|
||||
color: CHART_COLORS.eventMarker,
|
||||
size: 1,
|
||||
dashedValue: [2, 2],
|
||||
},
|
||||
text: {
|
||||
style: 'fill',
|
||||
color: CHART_COLORS.eventMarkerText,
|
||||
size: 12,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
offset: [0, 0],
|
||||
},
|
||||
rect: {
|
||||
style: 'fill',
|
||||
color: CHART_COLORS.eventMarker,
|
||||
borderColor: CHART_COLORS.eventMarker,
|
||||
borderSize: 1,
|
||||
borderRadius: 4,
|
||||
borderStyle: 'solid',
|
||||
borderDashedValue: [2, 2],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 深色主题配置
|
||||
*/
|
||||
export const darkTheme: DeepPartial<Styles> = {
|
||||
...lightTheme,
|
||||
candle: {
|
||||
...lightTheme.candle,
|
||||
tooltip: {
|
||||
...lightTheme.candle?.tooltip,
|
||||
text: {
|
||||
...lightTheme.candle?.tooltip?.text,
|
||||
color: CHART_COLORS.textDark,
|
||||
},
|
||||
},
|
||||
},
|
||||
indicator: {
|
||||
...lightTheme.indicator,
|
||||
tooltip: {
|
||||
...lightTheme.indicator?.tooltip,
|
||||
text: {
|
||||
...lightTheme.indicator?.tooltip?.text,
|
||||
color: CHART_COLORS.textDark,
|
||||
},
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
...lightTheme.xAxis,
|
||||
axisLine: {
|
||||
show: true,
|
||||
color: CHART_COLORS.borderDark,
|
||||
},
|
||||
tickLine: {
|
||||
show: true,
|
||||
length: 3,
|
||||
color: CHART_COLORS.borderDark,
|
||||
},
|
||||
tickText: {
|
||||
...lightTheme.xAxis?.tickText,
|
||||
color: CHART_COLORS.textDark,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
...lightTheme.yAxis,
|
||||
axisLine: {
|
||||
show: true,
|
||||
color: CHART_COLORS.borderDark,
|
||||
},
|
||||
tickLine: {
|
||||
show: true,
|
||||
length: 3,
|
||||
color: CHART_COLORS.borderDark,
|
||||
},
|
||||
tickText: {
|
||||
...lightTheme.yAxis?.tickText,
|
||||
color: CHART_COLORS.textDark,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
horizontal: {
|
||||
show: true,
|
||||
size: 1,
|
||||
color: CHART_COLORS.gridDark,
|
||||
style: 'dashed',
|
||||
},
|
||||
vertical: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
separator: {
|
||||
size: 1,
|
||||
color: CHART_COLORS.borderDark,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取主题配置(根据 Chakra UI colorMode)
|
||||
*/
|
||||
export const getTheme = (colorMode: 'light' | 'dark' = 'light'): DeepPartial<Styles> => {
|
||||
return colorMode === 'dark' ? darkTheme : lightTheme;
|
||||
};
|
||||
15
src/components/StockChart/hooks/index.ts
Normal file
15
src/components/StockChart/hooks/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* StockChart 自定义 Hooks 统一导出
|
||||
*
|
||||
* 使用方式:
|
||||
* import { useKLineChart, useKLineData, useEventMarker } from '@components/StockChart/hooks';
|
||||
*/
|
||||
|
||||
export { useKLineChart } from './useKLineChart';
|
||||
export type { UseKLineChartOptions, UseKLineChartReturn } from './useKLineChart';
|
||||
|
||||
export { useKLineData } from './useKLineData';
|
||||
export type { UseKLineDataOptions, UseKLineDataReturn } from './useKLineData';
|
||||
|
||||
export { useEventMarker } from './useEventMarker';
|
||||
export type { UseEventMarkerOptions, UseEventMarkerReturn } from './useEventMarker';
|
||||
209
src/components/StockChart/hooks/useEventMarker.ts
Normal file
209
src/components/StockChart/hooks/useEventMarker.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* useEventMarker Hook
|
||||
*
|
||||
* 管理事件标记的创建、更新和删除
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import type { Chart } from 'klinecharts';
|
||||
import type { EventMarker, KLineDataPoint } from '../types';
|
||||
import {
|
||||
createEventMarkerFromTime,
|
||||
createEventMarkerOverlay,
|
||||
removeAllEventMarkers,
|
||||
} from '../utils/eventMarkerUtils';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
export interface UseEventMarkerOptions {
|
||||
/** KLineChart 实例 */
|
||||
chart: Chart | null;
|
||||
/** K 线数据(用于定位标记) */
|
||||
data: KLineDataPoint[];
|
||||
/** 事件时间(ISO 字符串) */
|
||||
eventTime?: string;
|
||||
/** 事件标题(用于标记标签) */
|
||||
eventTitle?: string;
|
||||
/** 是否自动创建标记 */
|
||||
autoCreate?: boolean;
|
||||
}
|
||||
|
||||
export interface UseEventMarkerReturn {
|
||||
/** 当前标记 */
|
||||
marker: EventMarker | null;
|
||||
/** 标记 ID(已添加到图表) */
|
||||
markerId: string | null;
|
||||
/** 创建标记 */
|
||||
createMarker: (time: string, label: string, color?: string) => void;
|
||||
/** 移除标记 */
|
||||
removeMarker: () => void;
|
||||
/** 移除所有标记 */
|
||||
removeAllMarkers: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件标记管理 Hook
|
||||
*
|
||||
* @param options 配置选项
|
||||
* @returns UseEventMarkerReturn
|
||||
*
|
||||
* @example
|
||||
* const { marker, createMarker, removeMarker } = useEventMarker({
|
||||
* chart,
|
||||
* data,
|
||||
* eventTime: '2024-01-01 10:00:00',
|
||||
* eventTitle: '重大公告',
|
||||
* autoCreate: true,
|
||||
* });
|
||||
*/
|
||||
export const useEventMarker = (
|
||||
options: UseEventMarkerOptions
|
||||
): UseEventMarkerReturn => {
|
||||
const {
|
||||
chart,
|
||||
data,
|
||||
eventTime,
|
||||
eventTitle = '事件发生',
|
||||
autoCreate = true,
|
||||
} = options;
|
||||
|
||||
const [marker, setMarker] = useState<EventMarker | null>(null);
|
||||
const [markerId, setMarkerId] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* 创建事件标记
|
||||
*/
|
||||
const createMarker = useCallback(
|
||||
(time: string, label: string, color?: string) => {
|
||||
if (!chart || !data || data.length === 0) {
|
||||
logger.warn('useEventMarker', 'createMarker', '图表或数据未准备好', {
|
||||
hasChart: !!chart,
|
||||
dataLength: data?.length || 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 创建事件标记配置
|
||||
const eventMarker = createEventMarkerFromTime(time, label, color);
|
||||
setMarker(eventMarker);
|
||||
|
||||
// 2. 创建 Overlay
|
||||
const overlay = createEventMarkerOverlay(eventMarker, data);
|
||||
|
||||
if (!overlay) {
|
||||
logger.warn('useEventMarker', 'createMarker', 'Overlay 创建失败', {
|
||||
eventMarker,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 添加到图表
|
||||
const id = chart.createOverlay(overlay);
|
||||
|
||||
if (!id || (Array.isArray(id) && id.length === 0)) {
|
||||
logger.warn('useEventMarker', 'createMarker', '标记添加失败', {
|
||||
overlay,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actualId = Array.isArray(id) ? id[0] : id;
|
||||
setMarkerId(actualId as string);
|
||||
|
||||
logger.info('useEventMarker', 'createMarker', '事件标记创建成功', {
|
||||
markerId: actualId,
|
||||
label,
|
||||
time,
|
||||
chartId: chart.id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('useEventMarker', 'createMarker', err as Error, {
|
||||
time,
|
||||
label,
|
||||
});
|
||||
}
|
||||
},
|
||||
[chart, data]
|
||||
);
|
||||
|
||||
/**
|
||||
* 移除事件标记
|
||||
*/
|
||||
const removeMarker = useCallback(() => {
|
||||
if (!chart || !markerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
chart.removeOverlay(markerId);
|
||||
setMarker(null);
|
||||
setMarkerId(null);
|
||||
|
||||
logger.debug('useEventMarker', 'removeMarker', '移除事件标记', {
|
||||
markerId,
|
||||
chartId: chart.id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('useEventMarker', 'removeMarker', err as Error, {
|
||||
markerId,
|
||||
});
|
||||
}
|
||||
}, [chart, markerId]);
|
||||
|
||||
/**
|
||||
* 移除所有标记
|
||||
*/
|
||||
const removeAllMarkers = useCallback(() => {
|
||||
if (!chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
removeAllEventMarkers(chart);
|
||||
setMarker(null);
|
||||
setMarkerId(null);
|
||||
|
||||
logger.debug('useEventMarker', 'removeAllMarkers', '移除所有事件标记', {
|
||||
chartId: chart.id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('useEventMarker', 'removeAllMarkers', err as Error);
|
||||
}
|
||||
}, [chart]);
|
||||
|
||||
// 自动创建标记(当 eventTime 和数据都准备好时)
|
||||
useEffect(() => {
|
||||
if (
|
||||
autoCreate &&
|
||||
eventTime &&
|
||||
chart &&
|
||||
data &&
|
||||
data.length > 0 &&
|
||||
!markerId // 避免重复创建
|
||||
) {
|
||||
createMarker(eventTime, eventTitle);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventTime, chart, data, autoCreate]);
|
||||
|
||||
// 清理:组件卸载时移除所有标记
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (chart && markerId) {
|
||||
try {
|
||||
chart.removeOverlay(markerId);
|
||||
} catch (err) {
|
||||
// 忽略清理时的错误
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [chart, markerId]);
|
||||
|
||||
return {
|
||||
marker,
|
||||
markerId,
|
||||
createMarker,
|
||||
removeMarker,
|
||||
removeAllMarkers,
|
||||
};
|
||||
};
|
||||
173
src/components/StockChart/hooks/useKLineChart.ts
Normal file
173
src/components/StockChart/hooks/useKLineChart.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* useKLineChart Hook
|
||||
*
|
||||
* 管理 KLineChart 实例的初始化、配置和销毁
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { init, dispose } from 'klinecharts';
|
||||
import type { Chart } from 'klinecharts';
|
||||
import { useColorMode } from '@chakra-ui/react';
|
||||
import { getTheme } from '../config/klineTheme';
|
||||
import { CHART_INIT_OPTIONS } from '../config';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
export interface UseKLineChartOptions {
|
||||
/** 图表容器 ID */
|
||||
containerId: string;
|
||||
/** 图表高度(px) */
|
||||
height?: number;
|
||||
/** 是否自动调整大小 */
|
||||
autoResize?: boolean;
|
||||
}
|
||||
|
||||
export interface UseKLineChartReturn {
|
||||
/** KLineChart 实例 */
|
||||
chart: Chart | null;
|
||||
/** 容器 Ref */
|
||||
chartRef: React.RefObject<HTMLDivElement>;
|
||||
/** 是否已初始化 */
|
||||
isInitialized: boolean;
|
||||
/** 初始化错误 */
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* KLineChart 初始化和生命周期管理 Hook
|
||||
*
|
||||
* @param options 配置选项
|
||||
* @returns UseKLineChartReturn
|
||||
*
|
||||
* @example
|
||||
* const { chart, chartRef, isInitialized } = useKLineChart({
|
||||
* containerId: 'kline-chart',
|
||||
* height: 400,
|
||||
* autoResize: true,
|
||||
* });
|
||||
*/
|
||||
export const useKLineChart = (
|
||||
options: UseKLineChartOptions
|
||||
): UseKLineChartReturn => {
|
||||
const { containerId, height = 400, autoResize = true } = options;
|
||||
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstanceRef = useRef<Chart | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
// 图表初始化
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) {
|
||||
logger.warn('useKLineChart', 'init', '图表容器未挂载', { containerId });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug('useKLineChart', 'init', '开始初始化图表', {
|
||||
containerId,
|
||||
height,
|
||||
colorMode,
|
||||
});
|
||||
|
||||
// 初始化图表实例(KLineChart 10.0 API)
|
||||
const chartInstance = init(chartRef.current, {
|
||||
...CHART_INIT_OPTIONS,
|
||||
// 设置初始样式(根据主题)
|
||||
styles: getTheme(colorMode),
|
||||
});
|
||||
|
||||
if (!chartInstance) {
|
||||
throw new Error('图表初始化失败:返回 null');
|
||||
}
|
||||
|
||||
chartInstanceRef.current = chartInstance;
|
||||
setIsInitialized(true);
|
||||
setError(null);
|
||||
|
||||
logger.info('useKLineChart', 'init', '图表初始化成功', {
|
||||
containerId,
|
||||
chartId: chartInstance.id,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error('useKLineChart', 'init', error, { containerId });
|
||||
setError(error);
|
||||
setIsInitialized(false);
|
||||
}
|
||||
|
||||
// 清理函数:销毁图表实例
|
||||
return () => {
|
||||
if (chartInstanceRef.current) {
|
||||
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
|
||||
containerId,
|
||||
chartId: chartInstanceRef.current.id,
|
||||
});
|
||||
|
||||
dispose(chartInstanceRef.current);
|
||||
chartInstanceRef.current = null;
|
||||
setIsInitialized(false);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [containerId]); // 只在 containerId 变化时重新初始化
|
||||
|
||||
// 主题切换:更新图表样式
|
||||
useEffect(() => {
|
||||
if (!chartInstanceRef.current || !isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newTheme = getTheme(colorMode);
|
||||
chartInstanceRef.current.setStyles(newTheme);
|
||||
|
||||
logger.debug('useKLineChart', 'updateTheme', '更新图表主题', {
|
||||
colorMode,
|
||||
chartId: chartInstanceRef.current.id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('useKLineChart', 'updateTheme', err as Error, { colorMode });
|
||||
}
|
||||
}, [colorMode, isInitialized]);
|
||||
|
||||
// 容器尺寸变化:调整图表大小
|
||||
useEffect(() => {
|
||||
if (!chartInstanceRef.current || !isInitialized || !autoResize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
if (chartInstanceRef.current) {
|
||||
chartInstanceRef.current.resize();
|
||||
logger.debug('useKLineChart', 'resize', '调整图表大小');
|
||||
}
|
||||
};
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// 使用 ResizeObserver 监听容器大小变化(更精确)
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
if (chartRef.current && typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(chartRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (resizeObserver && chartRef.current) {
|
||||
resizeObserver.unobserve(chartRef.current);
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, [isInitialized, autoResize]);
|
||||
|
||||
return {
|
||||
chart: chartInstanceRef.current,
|
||||
chartRef,
|
||||
isInitialized,
|
||||
error,
|
||||
};
|
||||
};
|
||||
222
src/components/StockChart/hooks/useKLineData.ts
Normal file
222
src/components/StockChart/hooks/useKLineData.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* useKLineData Hook
|
||||
*
|
||||
* 管理 K 线数据的加载、转换和更新
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import type { Chart } from 'klinecharts';
|
||||
import type { ChartType, KLineDataPoint, RawDataPoint } from '../types';
|
||||
import { processChartData } from '../utils/dataAdapter';
|
||||
import { logger } from '@utils/logger';
|
||||
import { stockService } from '@services/stockService';
|
||||
|
||||
export interface UseKLineDataOptions {
|
||||
/** KLineChart 实例 */
|
||||
chart: Chart | null;
|
||||
/** 股票代码 */
|
||||
stockCode: string;
|
||||
/** 图表类型 */
|
||||
chartType: ChartType;
|
||||
/** 事件时间(用于调整数据加载范围) */
|
||||
eventTime?: string;
|
||||
/** 是否自动加载数据 */
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
export interface UseKLineDataReturn {
|
||||
/** 处理后的 K 线数据 */
|
||||
data: KLineDataPoint[];
|
||||
/** 原始数据 */
|
||||
rawData: RawDataPoint[];
|
||||
/** 是否加载中 */
|
||||
loading: boolean;
|
||||
/** 加载错误 */
|
||||
error: Error | null;
|
||||
/** 手动加载数据 */
|
||||
loadData: () => Promise<void>;
|
||||
/** 更新数据 */
|
||||
updateData: (newData: KLineDataPoint[]) => void;
|
||||
/** 清空数据 */
|
||||
clearData: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* K 线数据加载和管理 Hook
|
||||
*
|
||||
* @param options 配置选项
|
||||
* @returns UseKLineDataReturn
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error, loadData } = useKLineData({
|
||||
* chart,
|
||||
* stockCode: '600000.SH',
|
||||
* chartType: 'daily',
|
||||
* eventTime: '2024-01-01 10:00:00',
|
||||
* autoLoad: true,
|
||||
* });
|
||||
*/
|
||||
export const useKLineData = (
|
||||
options: UseKLineDataOptions
|
||||
): UseKLineDataReturn => {
|
||||
const {
|
||||
chart,
|
||||
stockCode,
|
||||
chartType,
|
||||
eventTime,
|
||||
autoLoad = true,
|
||||
} = options;
|
||||
|
||||
const [data, setData] = useState<KLineDataPoint[]>([]);
|
||||
const [rawData, setRawData] = useState<RawDataPoint[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
/**
|
||||
* 加载数据(从后端 API)
|
||||
*/
|
||||
const loadData = useCallback(async () => {
|
||||
if (!stockCode) {
|
||||
logger.warn('useKLineData', 'loadData', '股票代码为空', { chartType });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.debug('useKLineData', 'loadData', '开始加载数据', {
|
||||
stockCode,
|
||||
chartType,
|
||||
eventTime,
|
||||
});
|
||||
|
||||
// 调用后端 API 获取数据
|
||||
const response = await stockService.getKlineData(
|
||||
stockCode,
|
||||
chartType,
|
||||
eventTime
|
||||
);
|
||||
|
||||
if (!response || !response.data) {
|
||||
throw new Error('后端返回数据为空');
|
||||
}
|
||||
|
||||
const rawDataList = response.data;
|
||||
setRawData(rawDataList);
|
||||
|
||||
// 数据转换和处理
|
||||
const processedData = processChartData(rawDataList, chartType, eventTime);
|
||||
setData(processedData);
|
||||
|
||||
logger.info('useKLineData', 'loadData', '数据加载成功', {
|
||||
stockCode,
|
||||
chartType,
|
||||
rawCount: rawDataList.length,
|
||||
processedCount: processedData.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error('useKLineData', 'loadData', error, {
|
||||
stockCode,
|
||||
chartType,
|
||||
});
|
||||
setError(error);
|
||||
setData([]);
|
||||
setRawData([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode, chartType, eventTime]);
|
||||
|
||||
/**
|
||||
* 更新图表数据(使用 DataLoader 模式)
|
||||
*/
|
||||
const updateChartData = useCallback(
|
||||
(klineData: KLineDataPoint[]) => {
|
||||
if (!chart || klineData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// KLineChart 10.0: 使用 setDataLoader 方法
|
||||
chart.setDataLoader({
|
||||
getBars: (params) => {
|
||||
// 将数据传递给图表
|
||||
params.callback(klineData, { more: false });
|
||||
|
||||
logger.debug('useKLineData', 'updateChartData', 'DataLoader 回调', {
|
||||
dataCount: klineData.length,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('useKLineData', 'updateChartData', '图表数据已更新', {
|
||||
dataCount: klineData.length,
|
||||
chartId: chart.id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('useKLineData', 'updateChartData', err as Error, {
|
||||
dataCount: klineData.length,
|
||||
});
|
||||
}
|
||||
},
|
||||
[chart]
|
||||
);
|
||||
|
||||
/**
|
||||
* 手动更新数据(外部调用)
|
||||
*/
|
||||
const updateData = useCallback(
|
||||
(newData: KLineDataPoint[]) => {
|
||||
setData(newData);
|
||||
updateChartData(newData);
|
||||
|
||||
logger.debug('useKLineData', 'updateData', '手动更新数据', {
|
||||
newDataCount: newData.length,
|
||||
});
|
||||
},
|
||||
[updateChartData]
|
||||
);
|
||||
|
||||
/**
|
||||
* 清空数据
|
||||
*/
|
||||
const clearData = useCallback(() => {
|
||||
setData([]);
|
||||
setRawData([]);
|
||||
setError(null);
|
||||
|
||||
if (chart) {
|
||||
chart.resetData();
|
||||
logger.debug('useKLineData', 'clearData', '清空数据', {
|
||||
chartId: chart.id,
|
||||
});
|
||||
}
|
||||
}, [chart]);
|
||||
|
||||
// 自动加载数据(当 stockCode/chartType/eventTime 变化时)
|
||||
useEffect(() => {
|
||||
if (autoLoad && stockCode && chart) {
|
||||
loadData();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stockCode, chartType, eventTime, autoLoad, chart]);
|
||||
|
||||
// 数据变化时更新图表
|
||||
useEffect(() => {
|
||||
if (data.length > 0 && chart) {
|
||||
updateChartData(data);
|
||||
}
|
||||
}, [data, chart, updateChartData]);
|
||||
|
||||
return {
|
||||
data,
|
||||
rawData,
|
||||
loading,
|
||||
error,
|
||||
loadData,
|
||||
updateData,
|
||||
clearData,
|
||||
};
|
||||
};
|
||||
122
src/components/StockChart/types/chart.types.ts
Normal file
122
src/components/StockChart/types/chart.types.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* KLineChart 图表类型定义
|
||||
*
|
||||
* 适配 klinecharts@10.0.0-beta1
|
||||
* 文档: https://github.com/klinecharts/KLineChart
|
||||
*/
|
||||
|
||||
/**
|
||||
* K 线数据点(符合 KLineChart 10.0 规范)
|
||||
*
|
||||
* 注意: 10.0 版本要求 timestamp 为数字类型(毫秒时间戳)
|
||||
*/
|
||||
export interface KLineDataPoint {
|
||||
/** 时间戳(毫秒) */
|
||||
timestamp: number;
|
||||
/** 开盘价 */
|
||||
open: number;
|
||||
/** 最高价 */
|
||||
high: number;
|
||||
/** 最低价 */
|
||||
low: number;
|
||||
/** 收盘价 */
|
||||
close: number;
|
||||
/** 成交量 */
|
||||
volume: number;
|
||||
/** 成交额(可选) */
|
||||
turnover?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端原始数据格式
|
||||
*
|
||||
* 支持多种时间字段格式(time/date/timestamp)
|
||||
*/
|
||||
export interface RawDataPoint {
|
||||
/** 时间字符串(分时图格式:HH:mm) */
|
||||
time?: string;
|
||||
/** 日期字符串(日线格式:YYYY-MM-DD) */
|
||||
date?: string;
|
||||
/** 时间戳字符串或数字 */
|
||||
timestamp?: string | number;
|
||||
/** 开盘价 */
|
||||
open: number;
|
||||
/** 最高价 */
|
||||
high: number;
|
||||
/** 最低价 */
|
||||
low: number;
|
||||
/** 收盘价 */
|
||||
close: number;
|
||||
/** 成交量 */
|
||||
volume: number;
|
||||
/** 均价(分时图专用) */
|
||||
avg_price?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图表类型枚举
|
||||
*/
|
||||
export type ChartType = 'timeline' | 'daily';
|
||||
|
||||
/**
|
||||
* 图表配置接口
|
||||
*/
|
||||
export interface ChartConfig {
|
||||
/** 图表类型 */
|
||||
type: ChartType;
|
||||
/** 显示技术指标 */
|
||||
showIndicators: boolean;
|
||||
/** 默认技术指标列表 */
|
||||
defaultIndicators?: string[];
|
||||
/** 图表高度(px) */
|
||||
height?: number;
|
||||
/** 是否显示网格 */
|
||||
showGrid?: boolean;
|
||||
/** 是否显示十字光标 */
|
||||
showCrosshair?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件标记接口
|
||||
*
|
||||
* 用于在 K 线图上标记重要事件发生时间点
|
||||
*/
|
||||
export interface EventMarker {
|
||||
/** 唯一标识 */
|
||||
id: string;
|
||||
/** 时间戳(毫秒) */
|
||||
timestamp: number;
|
||||
/** 标签文本 */
|
||||
label: string;
|
||||
/** 标记位置 */
|
||||
position: 'top' | 'middle' | 'bottom';
|
||||
/** 标记颜色 */
|
||||
color: string;
|
||||
/** 图标(可选) */
|
||||
icon?: string;
|
||||
/** 是否可拖动(默认 false) */
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataLoader 回调参数(KLineChart 10.0 新增)
|
||||
*/
|
||||
export interface DataLoaderCallbackParams {
|
||||
/** K 线数据 */
|
||||
data: KLineDataPoint[];
|
||||
/** 是否还有更多数据 */
|
||||
more: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataLoader getBars 参数(KLineChart 10.0 新增)
|
||||
*/
|
||||
export interface DataLoaderGetBarsParams {
|
||||
/** 回调函数 */
|
||||
callback: (data: KLineDataPoint[], options?: { more: boolean }) => void;
|
||||
/** 范围参数(可选) */
|
||||
range?: {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
}
|
||||
25
src/components/StockChart/types/index.ts
Normal file
25
src/components/StockChart/types/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* StockChart 类型定义统一导出
|
||||
*
|
||||
* 使用方式:
|
||||
* import type { KLineDataPoint, StockInfo } from '@components/StockChart/types';
|
||||
*/
|
||||
|
||||
// 图表相关类型
|
||||
export type {
|
||||
KLineDataPoint,
|
||||
RawDataPoint,
|
||||
ChartType,
|
||||
ChartConfig,
|
||||
EventMarker,
|
||||
DataLoaderCallbackParams,
|
||||
DataLoaderGetBarsParams,
|
||||
} from './chart.types';
|
||||
|
||||
// 股票相关类型
|
||||
export type {
|
||||
StockInfo,
|
||||
ChartDataResponse,
|
||||
StockQuote,
|
||||
EventInfo,
|
||||
} from './stock.types';
|
||||
80
src/components/StockChart/types/stock.types.ts
Normal file
80
src/components/StockChart/types/stock.types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 股票相关类型定义
|
||||
*
|
||||
* 用于股票信息和图表数据的类型声明
|
||||
*/
|
||||
|
||||
import type { RawDataPoint } from './chart.types';
|
||||
|
||||
/**
|
||||
* 股票基础信息
|
||||
*/
|
||||
export interface StockInfo {
|
||||
/** 股票代码(如:600000.SH) */
|
||||
stock_code: string;
|
||||
/** 股票名称(如:浦发银行) */
|
||||
stock_name: string;
|
||||
/** 关联描述(可能是字符串或对象) */
|
||||
relation_desc?:
|
||||
| string
|
||||
| {
|
||||
/** 数据字段 */
|
||||
data?: string;
|
||||
/** 内容字段 */
|
||||
content?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 图表数据 API 响应格式
|
||||
*/
|
||||
export interface ChartDataResponse {
|
||||
/** K 线数据数组 */
|
||||
data: RawDataPoint[];
|
||||
/** 交易日期(YYYY-MM-DD) */
|
||||
trade_date?: string;
|
||||
/** 昨收价 */
|
||||
prev_close?: number;
|
||||
/** 状态码(可选) */
|
||||
code?: number;
|
||||
/** 消息(可选) */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 股票实时行情
|
||||
*/
|
||||
export interface StockQuote {
|
||||
/** 股票代码 */
|
||||
stock_code: string;
|
||||
/** 当前价 */
|
||||
price: number;
|
||||
/** 涨跌幅(%) */
|
||||
change_percent: number;
|
||||
/** 涨跌额 */
|
||||
change_amount: number;
|
||||
/** 成交量 */
|
||||
volume: number;
|
||||
/** 成交额 */
|
||||
turnover: number;
|
||||
/** 更新时间 */
|
||||
update_time: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件信息(用于事件中心)
|
||||
*/
|
||||
export interface EventInfo {
|
||||
/** 事件 ID */
|
||||
id: number | string;
|
||||
/** 事件标题 */
|
||||
title: string;
|
||||
/** 事件内容 */
|
||||
content: string;
|
||||
/** 事件发生时间(ISO 字符串) */
|
||||
event_time: string;
|
||||
/** 重要性等级(1-5) */
|
||||
importance?: number;
|
||||
/** 关联股票列表 */
|
||||
related_stocks?: StockInfo[];
|
||||
}
|
||||
295
src/components/StockChart/utils/chartUtils.ts
Normal file
295
src/components/StockChart/utils/chartUtils.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* 图表通用工具函数
|
||||
*
|
||||
* 包含图表初始化、技术指标管理等通用逻辑
|
||||
*/
|
||||
|
||||
import type { Chart } from 'klinecharts';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 安全地执行图表操作(捕获异常)
|
||||
*
|
||||
* @param operation 操作名称
|
||||
* @param fn 执行函数
|
||||
* @returns T | null 执行结果或 null
|
||||
*/
|
||||
export const safeChartOperation = <T>(
|
||||
operation: string,
|
||||
fn: () => T
|
||||
): T | null => {
|
||||
try {
|
||||
return fn();
|
||||
} catch (error) {
|
||||
logger.error('chartUtils', operation, error as Error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建技术指标
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param indicatorName 指标名称(如 'MA', 'MACD', 'VOL')
|
||||
* @param params 指标参数(可选)
|
||||
* @param isStack 是否叠加(主图指标为 true,副图为 false)
|
||||
* @returns string | null 指标 ID
|
||||
*/
|
||||
export const createIndicator = (
|
||||
chart: Chart,
|
||||
indicatorName: string,
|
||||
params?: number[],
|
||||
isStack: boolean = false
|
||||
): string | null => {
|
||||
return safeChartOperation(`createIndicator:${indicatorName}`, () => {
|
||||
const indicatorId = chart.createIndicator(
|
||||
{
|
||||
name: indicatorName,
|
||||
...(params && { calcParams: params }),
|
||||
},
|
||||
isStack
|
||||
);
|
||||
|
||||
logger.debug('chartUtils', 'createIndicator', '创建技术指标', {
|
||||
indicatorName,
|
||||
params,
|
||||
isStack,
|
||||
indicatorId,
|
||||
});
|
||||
|
||||
return indicatorId;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除技术指标
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param indicatorId 指标 ID(不传则移除所有指标)
|
||||
*/
|
||||
export const removeIndicator = (chart: Chart, indicatorId?: string): void => {
|
||||
safeChartOperation('removeIndicator', () => {
|
||||
chart.removeIndicator(indicatorId);
|
||||
logger.debug('chartUtils', 'removeIndicator', '移除技术指标', { indicatorId });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量创建副图指标
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param indicators 指标名称数组
|
||||
* @returns string[] 指标 ID 数组
|
||||
*/
|
||||
export const createSubIndicators = (
|
||||
chart: Chart,
|
||||
indicators: string[]
|
||||
): string[] => {
|
||||
const ids: string[] = [];
|
||||
|
||||
indicators.forEach((name) => {
|
||||
const id = createIndicator(chart, name, undefined, false);
|
||||
if (id) {
|
||||
ids.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', 'createSubIndicators', '批量创建副图指标', {
|
||||
indicators,
|
||||
createdIds: ids,
|
||||
});
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置图表缩放级别
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param zoom 缩放级别(0.5 - 2.0)
|
||||
*/
|
||||
export const setChartZoom = (chart: Chart, zoom: number): void => {
|
||||
safeChartOperation('setChartZoom', () => {
|
||||
// KLineChart 10.0: 使用 setBarSpace 方法调整 K 线宽度(实现缩放效果)
|
||||
const baseBarSpace = 8; // 默认 K 线宽度(px)
|
||||
const newBarSpace = Math.max(4, Math.min(16, baseBarSpace * zoom));
|
||||
|
||||
// 注意:KLineChart 10.0 可能没有直接的 zoom API,需要通过调整样式实现
|
||||
chart.setStyles({
|
||||
candle: {
|
||||
bar: {
|
||||
upBorderColor: undefined, // 保持默认
|
||||
upColor: undefined,
|
||||
downBorderColor: undefined,
|
||||
downColor: undefined,
|
||||
},
|
||||
// 通过调整蜡烛图宽度实现缩放效果
|
||||
tooltip: {
|
||||
showRule: 'always',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', 'setChartZoom', '设置图表缩放', {
|
||||
zoom,
|
||||
newBarSpace,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 滚动到指定时间
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param timestamp 目标时间戳
|
||||
*/
|
||||
export const scrollToTimestamp = (chart: Chart, timestamp: number): void => {
|
||||
safeChartOperation('scrollToTimestamp', () => {
|
||||
// KLineChart 10.0: 使用 scrollToTimestamp 方法
|
||||
chart.scrollToTimestamp(timestamp);
|
||||
|
||||
logger.debug('chartUtils', 'scrollToTimestamp', '滚动到指定时间', { timestamp });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 调整图表大小(响应式)
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
*/
|
||||
export const resizeChart = (chart: Chart): void => {
|
||||
safeChartOperation('resizeChart', () => {
|
||||
chart.resize();
|
||||
logger.debug('chartUtils', 'resizeChart', '调整图表大小');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取图表可见数据范围
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @returns { from: number, to: number } | null 可见范围
|
||||
*/
|
||||
export const getVisibleRange = (chart: Chart): { from: number; to: number } | null => {
|
||||
return safeChartOperation('getVisibleRange', () => {
|
||||
const data = chart.getDataList();
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 简化实现:返回所有数据范围
|
||||
// 实际项目中可通过 chart 的内部状态获取可见范围
|
||||
return {
|
||||
from: 0,
|
||||
to: data.length - 1,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空图表数据
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
*/
|
||||
export const clearChartData = (chart: Chart): void => {
|
||||
safeChartOperation('clearChartData', () => {
|
||||
chart.resetData();
|
||||
logger.debug('chartUtils', 'clearChartData', '清空图表数据');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 截图(导出图表为图片)
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param includeOverlay 是否包含 overlay
|
||||
* @returns string | null Base64 图片数据
|
||||
*/
|
||||
export const exportChartImage = (
|
||||
chart: Chart,
|
||||
includeOverlay: boolean = true
|
||||
): string | null => {
|
||||
return safeChartOperation('exportChartImage', () => {
|
||||
// KLineChart 10.0: 使用 getConvertPictureUrl 方法
|
||||
const imageData = chart.getConvertPictureUrl(includeOverlay, 'png', '#ffffff');
|
||||
|
||||
logger.debug('chartUtils', 'exportChartImage', '导出图表图片', {
|
||||
includeOverlay,
|
||||
hasData: !!imageData,
|
||||
});
|
||||
|
||||
return imageData;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换十字光标显示
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param show 是否显示
|
||||
*/
|
||||
export const toggleCrosshair = (chart: Chart, show: boolean): void => {
|
||||
safeChartOperation('toggleCrosshair', () => {
|
||||
chart.setStyles({
|
||||
crosshair: {
|
||||
show,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', 'toggleCrosshair', '切换十字光标', { show });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换网格显示
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param show 是否显示
|
||||
*/
|
||||
export const toggleGrid = (chart: Chart, show: boolean): void => {
|
||||
safeChartOperation('toggleGrid', () => {
|
||||
chart.setStyles({
|
||||
grid: {
|
||||
show,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', 'toggleGrid', '切换网格', { show });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 订阅图表事件
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param eventName 事件名称
|
||||
* @param handler 事件处理函数
|
||||
*/
|
||||
export const subscribeChartEvent = (
|
||||
chart: Chart,
|
||||
eventName: string,
|
||||
handler: (...args: any[]) => void
|
||||
): void => {
|
||||
safeChartOperation(`subscribeChartEvent:${eventName}`, () => {
|
||||
chart.subscribeAction(eventName, handler);
|
||||
logger.debug('chartUtils', 'subscribeChartEvent', '订阅图表事件', { eventName });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消订阅图表事件
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param eventName 事件名称
|
||||
* @param handler 事件处理函数
|
||||
*/
|
||||
export const unsubscribeChartEvent = (
|
||||
chart: Chart,
|
||||
eventName: string,
|
||||
handler: (...args: any[]) => void
|
||||
): void => {
|
||||
safeChartOperation(`unsubscribeChartEvent:${eventName}`, () => {
|
||||
chart.unsubscribeAction(eventName, handler);
|
||||
logger.debug('chartUtils', 'unsubscribeChartEvent', '取消订阅图表事件', { eventName });
|
||||
});
|
||||
};
|
||||
257
src/components/StockChart/utils/dataAdapter.ts
Normal file
257
src/components/StockChart/utils/dataAdapter.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 数据转换适配器
|
||||
*
|
||||
* 将后端返回的各种格式数据转换为 KLineChart 10.0 所需的标准格式
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import type { KLineDataPoint, RawDataPoint, ChartType } from '../types';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 将后端原始数据转换为 KLineChart 标准格式
|
||||
*
|
||||
* @param rawData 后端原始数据数组
|
||||
* @param chartType 图表类型(timeline/daily)
|
||||
* @param eventTime 事件时间(用于日期基准)
|
||||
* @returns KLineDataPoint[] 标准K线数据
|
||||
*/
|
||||
export const convertToKLineData = (
|
||||
rawData: RawDataPoint[],
|
||||
chartType: ChartType,
|
||||
eventTime?: string
|
||||
): KLineDataPoint[] => {
|
||||
if (!rawData || !Array.isArray(rawData) || rawData.length === 0) {
|
||||
logger.warn('dataAdapter', 'convertToKLineData', '原始数据为空', { chartType });
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return rawData.map((item, index) => {
|
||||
const timestamp = parseTimestamp(item, chartType, eventTime, index);
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
open: Number(item.open) || 0,
|
||||
high: Number(item.high) || 0,
|
||||
low: Number(item.low) || 0,
|
||||
close: Number(item.close) || 0,
|
||||
volume: Number(item.volume) || 0,
|
||||
turnover: item.turnover ? Number(item.turnover) : undefined,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('dataAdapter', 'convertToKLineData', error as Error, {
|
||||
chartType,
|
||||
dataLength: rawData.length,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析时间戳(兼容多种时间格式)
|
||||
*
|
||||
* @param item 原始数据项
|
||||
* @param chartType 图表类型
|
||||
* @param eventTime 事件时间
|
||||
* @param index 数据索引(用于分时图时间推算)
|
||||
* @returns number 毫秒时间戳
|
||||
*/
|
||||
const parseTimestamp = (
|
||||
item: RawDataPoint,
|
||||
chartType: ChartType,
|
||||
eventTime?: string,
|
||||
index?: number
|
||||
): number => {
|
||||
// 优先级1: 使用 timestamp 字段
|
||||
if (item.timestamp) {
|
||||
const ts = typeof item.timestamp === 'number' ? item.timestamp : Number(item.timestamp);
|
||||
// 判断是秒级还是毫秒级时间戳
|
||||
return ts > 10000000000 ? ts : ts * 1000;
|
||||
}
|
||||
|
||||
// 优先级2: 使用 date 字段(日K线)
|
||||
if (item.date) {
|
||||
return dayjs(item.date).valueOf();
|
||||
}
|
||||
|
||||
// 优先级3: 使用 time 字段(分时图)
|
||||
if (item.time && eventTime) {
|
||||
return parseTimelineTimestamp(item.time, eventTime);
|
||||
}
|
||||
|
||||
// 优先级4: 根据 chartType 和 index 推算(兜底逻辑)
|
||||
if (chartType === 'timeline' && eventTime && typeof index === 'number') {
|
||||
// 分时图:从事件时间推算(假设 09:30 开盘)
|
||||
const baseTime = dayjs(eventTime).startOf('day').add(9, 'hour').add(30, 'minute');
|
||||
return baseTime.add(index, 'minute').valueOf();
|
||||
}
|
||||
|
||||
// 默认返回当前时间(避免图表崩溃)
|
||||
logger.warn('dataAdapter', 'parseTimestamp', '无法解析时间戳,使用当前时间', { item });
|
||||
return Date.now();
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析分时图时间戳
|
||||
*
|
||||
* 将 "HH:mm" 格式转换为完整时间戳
|
||||
*
|
||||
* @param time 时间字符串(如 "09:30")
|
||||
* @param eventTime 事件时间(YYYY-MM-DD HH:mm:ss)
|
||||
* @returns number 毫秒时间戳
|
||||
*/
|
||||
const parseTimelineTimestamp = (time: string, eventTime: string): number => {
|
||||
try {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
const eventDate = dayjs(eventTime).startOf('day');
|
||||
return eventDate.hour(hours).minute(minutes).second(0).valueOf();
|
||||
} catch (error) {
|
||||
logger.error('dataAdapter', 'parseTimelineTimestamp', error as Error, { time, eventTime });
|
||||
return dayjs(eventTime).valueOf();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 数据验证和清洗
|
||||
*
|
||||
* 移除无效数据(价格/成交量异常)
|
||||
*
|
||||
* @param data K线数据
|
||||
* @returns KLineDataPoint[] 清洗后的数据
|
||||
*/
|
||||
export const validateAndCleanData = (data: KLineDataPoint[]): KLineDataPoint[] => {
|
||||
return data.filter((item) => {
|
||||
// 移除价格为 0 或负数的数据
|
||||
if (item.open <= 0 || item.high <= 0 || item.low <= 0 || item.close <= 0) {
|
||||
logger.warn('dataAdapter', 'validateAndCleanData', '价格异常,已移除', { item });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 移除 high < low 的数据(数据错误)
|
||||
if (item.high < item.low) {
|
||||
logger.warn('dataAdapter', 'validateAndCleanData', '最高价 < 最低价,已移除', { item });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 移除成交量为负数的数据
|
||||
if (item.volume < 0) {
|
||||
logger.warn('dataAdapter', 'validateAndCleanData', '成交量异常,已移除', { item });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 数据排序(按时间升序)
|
||||
*
|
||||
* @param data K线数据
|
||||
* @returns KLineDataPoint[] 排序后的数据
|
||||
*/
|
||||
export const sortDataByTime = (data: KLineDataPoint[]): KLineDataPoint[] => {
|
||||
return [...data].sort((a, b) => a.timestamp - b.timestamp);
|
||||
};
|
||||
|
||||
/**
|
||||
* 数据去重(移除时间戳重复的数据,保留最后一条)
|
||||
*
|
||||
* @param data K线数据
|
||||
* @returns KLineDataPoint[] 去重后的数据
|
||||
*/
|
||||
export const deduplicateData = (data: KLineDataPoint[]): KLineDataPoint[] => {
|
||||
const map = new Map<number, KLineDataPoint>();
|
||||
|
||||
data.forEach((item) => {
|
||||
map.set(item.timestamp, item); // 相同时间戳会覆盖
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* 完整的数据处理流程
|
||||
*
|
||||
* 转换 → 验证 → 去重 → 排序
|
||||
*
|
||||
* @param rawData 后端原始数据
|
||||
* @param chartType 图表类型
|
||||
* @param eventTime 事件时间
|
||||
* @returns KLineDataPoint[] 处理后的数据
|
||||
*/
|
||||
export const processChartData = (
|
||||
rawData: RawDataPoint[],
|
||||
chartType: ChartType,
|
||||
eventTime?: string
|
||||
): KLineDataPoint[] => {
|
||||
// 1. 转换数据格式
|
||||
let data = convertToKLineData(rawData, chartType, eventTime);
|
||||
|
||||
// 2. 验证和清洗
|
||||
data = validateAndCleanData(data);
|
||||
|
||||
// 3. 去重
|
||||
data = deduplicateData(data);
|
||||
|
||||
// 4. 排序
|
||||
data = sortDataByTime(data);
|
||||
|
||||
logger.debug('dataAdapter', 'processChartData', '数据处理完成', {
|
||||
rawLength: rawData.length,
|
||||
processedLength: data.length,
|
||||
chartType,
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取数据时间范围
|
||||
*
|
||||
* @param data K线数据
|
||||
* @returns { start: number, end: number } 时间范围(毫秒时间戳)
|
||||
*/
|
||||
export const getDataTimeRange = (
|
||||
data: KLineDataPoint[]
|
||||
): { start: number; end: number } | null => {
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamps = data.map((item) => item.timestamp);
|
||||
return {
|
||||
start: Math.min(...timestamps),
|
||||
end: Math.max(...timestamps),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 查找最接近指定时间的数据点
|
||||
*
|
||||
* @param data K线数据
|
||||
* @param targetTime 目标时间戳
|
||||
* @returns KLineDataPoint | null 最接近的数据点
|
||||
*/
|
||||
export const findClosestDataPoint = (
|
||||
data: KLineDataPoint[],
|
||||
targetTime: number
|
||||
): KLineDataPoint | null => {
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let closest = data[0];
|
||||
let minDiff = Math.abs(data[0].timestamp - targetTime);
|
||||
|
||||
data.forEach((item) => {
|
||||
const diff = Math.abs(item.timestamp - targetTime);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = item;
|
||||
}
|
||||
});
|
||||
|
||||
return closest;
|
||||
};
|
||||
305
src/components/StockChart/utils/eventMarkerUtils.ts
Normal file
305
src/components/StockChart/utils/eventMarkerUtils.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* 事件标记工具函数
|
||||
*
|
||||
* 用于在 K 线图上创建、管理事件标记(Overlay)
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import type { OverlayCreate } from 'klinecharts';
|
||||
import type { EventMarker, KLineDataPoint } from '../types';
|
||||
import { EVENT_MARKER_CONFIG } from '../config';
|
||||
import { findClosestDataPoint } from './dataAdapter';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 创建事件标记 Overlay(KLineChart 10.0 格式)
|
||||
*
|
||||
* @param marker 事件标记配置
|
||||
* @param data K线数据(用于定位标记位置)
|
||||
* @returns OverlayCreate | null Overlay 配置对象
|
||||
*/
|
||||
export const createEventMarkerOverlay = (
|
||||
marker: EventMarker,
|
||||
data: KLineDataPoint[]
|
||||
): OverlayCreate | null => {
|
||||
try {
|
||||
// 查找最接近事件时间的数据点
|
||||
const closestPoint = findClosestDataPoint(data, marker.timestamp);
|
||||
|
||||
if (!closestPoint) {
|
||||
logger.warn('eventMarkerUtils', 'createEventMarkerOverlay', '未找到匹配的数据点', {
|
||||
markerId: marker.id,
|
||||
timestamp: marker.timestamp,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据位置计算 Y 坐标
|
||||
const yValue = calculateMarkerYPosition(closestPoint, marker.position);
|
||||
|
||||
// 创建 Overlay 配置(KLineChart 10.0 规范)
|
||||
const overlay: OverlayCreate = {
|
||||
name: 'simpleAnnotation', // 使用内置的简单标注类型
|
||||
id: marker.id,
|
||||
points: [
|
||||
{
|
||||
timestamp: closestPoint.timestamp,
|
||||
value: yValue,
|
||||
},
|
||||
],
|
||||
styles: {
|
||||
point: {
|
||||
color: marker.color,
|
||||
borderColor: marker.color,
|
||||
borderSize: 2,
|
||||
radius: EVENT_MARKER_CONFIG.size.point,
|
||||
},
|
||||
text: {
|
||||
color: EVENT_MARKER_CONFIG.text.color,
|
||||
size: EVENT_MARKER_CONFIG.text.fontSize,
|
||||
family: EVENT_MARKER_CONFIG.text.fontFamily,
|
||||
weight: 'bold',
|
||||
},
|
||||
rect: {
|
||||
style: 'fill',
|
||||
color: marker.color,
|
||||
borderRadius: EVENT_MARKER_CONFIG.text.borderRadius,
|
||||
paddingLeft: EVENT_MARKER_CONFIG.text.padding,
|
||||
paddingRight: EVENT_MARKER_CONFIG.text.padding,
|
||||
paddingTop: EVENT_MARKER_CONFIG.text.padding,
|
||||
paddingBottom: EVENT_MARKER_CONFIG.text.padding,
|
||||
},
|
||||
},
|
||||
// 标记文本内容
|
||||
extendData: {
|
||||
label: marker.label,
|
||||
icon: marker.icon,
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug('eventMarkerUtils', 'createEventMarkerOverlay', '创建事件标记', {
|
||||
markerId: marker.id,
|
||||
timestamp: closestPoint.timestamp,
|
||||
label: marker.label,
|
||||
});
|
||||
|
||||
return overlay;
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'createEventMarkerOverlay', error as Error, {
|
||||
markerId: marker.id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算标记的 Y 轴位置
|
||||
*
|
||||
* @param dataPoint K线数据点
|
||||
* @param position 标记位置(top/middle/bottom)
|
||||
* @returns number Y轴数值
|
||||
*/
|
||||
const calculateMarkerYPosition = (
|
||||
dataPoint: KLineDataPoint,
|
||||
position: 'top' | 'middle' | 'bottom'
|
||||
): number => {
|
||||
switch (position) {
|
||||
case 'top':
|
||||
return dataPoint.high * 1.02; // 在最高价上方 2%
|
||||
case 'bottom':
|
||||
return dataPoint.low * 0.98; // 在最低价下方 2%
|
||||
case 'middle':
|
||||
default:
|
||||
return (dataPoint.high + dataPoint.low) / 2; // 中间位置
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从事件时间创建标记配置
|
||||
*
|
||||
* @param eventTime 事件时间字符串(ISO 格式)
|
||||
* @param label 标记标签(可选,默认为"事件发生")
|
||||
* @param color 标记颜色(可选,使用默认颜色)
|
||||
* @returns EventMarker 事件标记配置
|
||||
*/
|
||||
export const createEventMarkerFromTime = (
|
||||
eventTime: string,
|
||||
label: string = '事件发生',
|
||||
color: string = EVENT_MARKER_CONFIG.defaultColor
|
||||
): EventMarker => {
|
||||
const timestamp = dayjs(eventTime).valueOf();
|
||||
|
||||
return {
|
||||
id: `event-${timestamp}`,
|
||||
timestamp,
|
||||
label,
|
||||
position: EVENT_MARKER_CONFIG.defaultPosition,
|
||||
color,
|
||||
icon: EVENT_MARKER_CONFIG.defaultIcon,
|
||||
draggable: false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量创建事件标记 Overlays
|
||||
*
|
||||
* @param markers 事件标记配置数组
|
||||
* @param data K线数据
|
||||
* @returns OverlayCreate[] Overlay 配置数组
|
||||
*/
|
||||
export const createEventMarkerOverlays = (
|
||||
markers: EventMarker[],
|
||||
data: KLineDataPoint[]
|
||||
): OverlayCreate[] => {
|
||||
if (!markers || markers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const overlays: OverlayCreate[] = [];
|
||||
|
||||
markers.forEach((marker) => {
|
||||
const overlay = createEventMarkerOverlay(marker, data);
|
||||
if (overlay) {
|
||||
overlays.push(overlay);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug('eventMarkerUtils', 'createEventMarkerOverlays', '批量创建事件标记', {
|
||||
totalMarkers: markers.length,
|
||||
createdOverlays: overlays.length,
|
||||
});
|
||||
|
||||
return overlays;
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除事件标记
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param markerId 标记 ID
|
||||
*/
|
||||
export const removeEventMarker = (chart: any, markerId: string): void => {
|
||||
try {
|
||||
chart.removeOverlay(markerId);
|
||||
logger.debug('eventMarkerUtils', 'removeEventMarker', '移除事件标记', { markerId });
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'removeEventMarker', error as Error, { markerId });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除所有事件标记
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
*/
|
||||
export const removeAllEventMarkers = (chart: any): void => {
|
||||
try {
|
||||
// KLineChart 10.0 API: removeOverlay() 不传参数时移除所有 overlays
|
||||
chart.removeOverlay();
|
||||
logger.debug('eventMarkerUtils', 'removeAllEventMarkers', '移除所有事件标记');
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'removeAllEventMarkers', error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新事件标记
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param markerId 标记 ID
|
||||
* @param updates 更新内容(部分字段)
|
||||
*/
|
||||
export const updateEventMarker = (
|
||||
chart: any,
|
||||
markerId: string,
|
||||
updates: Partial<EventMarker>
|
||||
): void => {
|
||||
try {
|
||||
// 先移除旧标记
|
||||
removeEventMarker(chart, markerId);
|
||||
|
||||
// 重新创建标记(KLineChart 10.0 不支持直接更新 overlay)
|
||||
// 注意:需要在调用方重新创建并添加 overlay
|
||||
|
||||
logger.debug('eventMarkerUtils', 'updateEventMarker', '更新事件标记', {
|
||||
markerId,
|
||||
updates,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'updateEventMarker', error as Error, { markerId });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 高亮事件标记(改变样式)
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param markerId 标记 ID
|
||||
* @param highlight 是否高亮
|
||||
*/
|
||||
export const highlightEventMarker = (
|
||||
chart: any,
|
||||
markerId: string,
|
||||
highlight: boolean
|
||||
): void => {
|
||||
try {
|
||||
// KLineChart 10.0: 通过 overrideOverlay 修改样式
|
||||
chart.overrideOverlay({
|
||||
id: markerId,
|
||||
styles: {
|
||||
point: {
|
||||
activeRadius: highlight ? 10 : EVENT_MARKER_CONFIG.size.point,
|
||||
activeBorderSize: highlight ? 3 : 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('eventMarkerUtils', 'highlightEventMarker', '高亮事件标记', {
|
||||
markerId,
|
||||
highlight,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'highlightEventMarker', error as Error, { markerId });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化事件标记标签
|
||||
*
|
||||
* @param eventTitle 事件标题
|
||||
* @param maxLength 最大长度(默认 10)
|
||||
* @returns string 格式化后的标签
|
||||
*/
|
||||
export const formatEventMarkerLabel = (eventTitle: string, maxLength: number = 10): string => {
|
||||
if (!eventTitle) {
|
||||
return '事件';
|
||||
}
|
||||
|
||||
if (eventTitle.length <= maxLength) {
|
||||
return eventTitle;
|
||||
}
|
||||
|
||||
return `${eventTitle.substring(0, maxLength)}...`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断事件时间是否在数据范围内
|
||||
*
|
||||
* @param eventTime 事件时间戳
|
||||
* @param data K线数据
|
||||
* @returns boolean 是否在范围内
|
||||
*/
|
||||
export const isEventTimeInDataRange = (
|
||||
eventTime: number,
|
||||
data: KLineDataPoint[]
|
||||
): boolean => {
|
||||
if (!data || data.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timestamps = data.map((item) => item.timestamp);
|
||||
const minTime = Math.min(...timestamps);
|
||||
const maxTime = Math.max(...timestamps);
|
||||
|
||||
return eventTime >= minTime && eventTime <= maxTime;
|
||||
};
|
||||
48
src/components/StockChart/utils/index.ts
Normal file
48
src/components/StockChart/utils/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* StockChart 工具函数统一导出
|
||||
*
|
||||
* 使用方式:
|
||||
* import { processChartData, createEventMarkerOverlay } from '@components/StockChart/utils';
|
||||
*/
|
||||
|
||||
// 数据转换适配器
|
||||
export {
|
||||
convertToKLineData,
|
||||
validateAndCleanData,
|
||||
sortDataByTime,
|
||||
deduplicateData,
|
||||
processChartData,
|
||||
getDataTimeRange,
|
||||
findClosestDataPoint,
|
||||
} from './dataAdapter';
|
||||
|
||||
// 事件标记工具
|
||||
export {
|
||||
createEventMarkerOverlay,
|
||||
createEventMarkerFromTime,
|
||||
createEventMarkerOverlays,
|
||||
removeEventMarker,
|
||||
removeAllEventMarkers,
|
||||
updateEventMarker,
|
||||
highlightEventMarker,
|
||||
formatEventMarkerLabel,
|
||||
isEventTimeInDataRange,
|
||||
} from './eventMarkerUtils';
|
||||
|
||||
// 图表通用工具
|
||||
export {
|
||||
safeChartOperation,
|
||||
createIndicator,
|
||||
removeIndicator,
|
||||
createSubIndicators,
|
||||
setChartZoom,
|
||||
scrollToTimestamp,
|
||||
resizeChart,
|
||||
getVisibleRange,
|
||||
clearChartData,
|
||||
exportChartImage,
|
||||
toggleCrosshair,
|
||||
toggleGrid,
|
||||
subscribeChartEvent,
|
||||
unsubscribeChartEvent,
|
||||
} from './chartUtils';
|
||||
Reference in New Issue
Block a user