diff --git a/src/components/StockChart/config/chartConfig.ts b/src/components/StockChart/config/chartConfig.ts new file mode 100644 index 00000000..7cd91fe3 --- /dev/null +++ b/src/components/StockChart/config/chartConfig.ts @@ -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 = { + 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; diff --git a/src/components/StockChart/utils/chartUtils.ts b/src/components/StockChart/utils/chartUtils.ts new file mode 100644 index 00000000..de2de104 --- /dev/null +++ b/src/components/StockChart/utils/chartUtils.ts @@ -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 = ( + 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 }); + }); +}; diff --git a/src/components/StockChart/utils/dataAdapter.ts b/src/components/StockChart/utils/dataAdapter.ts new file mode 100644 index 00000000..5608e725 --- /dev/null +++ b/src/components/StockChart/utils/dataAdapter.ts @@ -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(); + + 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; +}; diff --git a/src/components/StockChart/utils/eventMarkerUtils.ts b/src/components/StockChart/utils/eventMarkerUtils.ts new file mode 100644 index 00000000..ed3e4740 --- /dev/null +++ b/src/components/StockChart/utils/eventMarkerUtils.ts @@ -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 +): 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; +}; diff --git a/src/components/StockChart/utils/index.ts b/src/components/StockChart/utils/index.ts new file mode 100644 index 00000000..f0ba7b5f --- /dev/null +++ b/src/components/StockChart/utils/index.ts @@ -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';