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

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

View File

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

View File

@@ -9,7 +9,8 @@ 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';
import { stockService } from '@services/eventService';
import { klineDataCache, getCacheKey } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
export interface UseKLineDataOptions {
/** KLineChart 实例 */
@@ -91,22 +92,38 @@ export const useKLineData = (
eventTime,
});
// 调用后端 API 获取数据
const response = await stockService.getKlineData(
stockCode,
chartType,
eventTime
);
// 1. 先检查缓存
const cacheKey = getCacheKey(stockCode, eventTime, chartType);
const cachedData = klineDataCache.get(cacheKey);
if (!response || !response.data) {
throw new Error('后端返回数据为空');
let rawDataList;
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);
// 数据转换和处理
const processedData = processChartData(rawDataList, chartType, eventTime);
setData(processedData);
logger.info('useKLineData', 'loadData', '数据加载成功', {
@@ -130,7 +147,7 @@ export const useKLineData = (
}, [stockCode, chartType, eventTime]);
/**
* 更新图表数据(使用 DataLoader 模式
* 更新图表数据(使用 setDataLoader 方法
*/
const updateChartData = useCallback(
(klineData: KLineDataPoint[]) => {
@@ -139,29 +156,120 @@ export const useKLineData = (
}
try {
// KLineChart 10.0: 使用 setDataLoader 方法
chart.setDataLoader({
getBars: (params) => {
// 将数据传递给图表
params.callback(klineData, { more: false });
// 步骤 1: 设置 symbol必需getBars 调用的前置条件)
(chart as any).setSymbol({
ticker: stockCode || 'UNKNOWN', // 股票代码
pricePrecision: 2, // 价格精度2位小数
volumePrecision: 0 // 成交量精度(整数)
});
logger.debug('useKLineData', 'updateChartData', 'DataLoader 回调', {
dataCount: klineData.length,
// 步骤 2: 设置 period必需getBars 调用的前置条件)
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', '图表数据已更新', {
dataCount: klineData.length,
chartId: chart.id,
});
// ✅ 步骤 4: 分时图添加均价线(使用自定义 AVG 指标)
if (chartType === 'timeline' && klineData.length > 0) {
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) {
logger.error('useKLineData', 'updateChartData', err as Error, {
dataCount: klineData.length,
});
}
},
[chart]
[chart, stockCode, chartType]
);
/**
@@ -172,9 +280,10 @@ export const useKLineData = (
setData(newData);
updateChartData(newData);
logger.debug('useKLineData', 'updateData', '手动更新数据', {
newDataCount: newData.length,
});
logger.debug(
'useKLineData',
`updateData - ${stockCode} (${chartType}) - ${newData.length}条数据手动更新`
);
},
[updateChartData]
);
@@ -189,9 +298,7 @@ export const useKLineData = (
if (chart) {
chart.resetData();
logger.debug('useKLineData', 'clearData', '清空数据', {
chartId: chart.id,
});
logger.debug('useKLineData', `clearData - chartId: ${(chart as any).id}`);
}
}, [chart]);