feat: 现在创建配置文件。首先让我检查现有的主题配置,以保持样式一致性:
This commit is contained in:
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;
|
||||
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