From ad2a374069a5eeddd485bfc28806da33482071bd Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Sat, 22 Nov 2025 12:42:33 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E5=AE=8C=E5=85=A8=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E7=8E=B0=E6=9C=89=E7=9A=84=20ECharts=20=E8=82=A1?= =?UTF-8?q?=E7=A5=A8=E5=9B=BE=E8=A1=A8=E5=BC=B9=E7=AA=97=EF=BC=8C=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E4=B8=93=E4=B8=9A=E7=9A=84=20KLineChart=20=E5=BA=93?= =?UTF-8?q?=20+=20TypeScript=EF=BC=8C=E6=8F=90=E5=8D=87=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E5=92=8C=E5=8F=AF=E7=BB=B4=E6=8A=A4=E6=80=A7=E3=80=82=20?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index 08182b91..b0073cec 100755 --- a/package.json +++ b/package.json @@ -37,8 +37,10 @@ "echarts-for-react": "^3.0.2", "echarts-wordcloud": "^2.1.0", "framer-motion": "^12.23.24", + "fullcalendar": "^5.9.0", "globalize": "^1.7.0", "history": "^5.3.0", + "klinecharts": "^10.0.0-beta1", "lucide-react": "^0.540.0", "match-sorter": "6.3.0", "nouislider": "15.0.0", From 5bb8a175880bdd8647f1e852a038ccf5ce1bbb09 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Sat, 22 Nov 2025 23:10:00 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=AE=9A=E4=B9=89=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StockChart/types/chart.types.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/components/StockChart/types/chart.types.ts diff --git a/src/components/StockChart/types/chart.types.ts b/src/components/StockChart/types/chart.types.ts new file mode 100644 index 00000000..c0454fe9 --- /dev/null +++ b/src/components/StockChart/types/chart.types.ts @@ -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; + }; +} From 06916cdde5c87bcff52af2857da15cf70a37766a Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Sat, 22 Nov 2025 23:10:28 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat:=E8=82=A1=E7=A5=A8=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StockChart/types/stock.types.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/components/StockChart/types/stock.types.ts diff --git a/src/components/StockChart/types/stock.types.ts b/src/components/StockChart/types/stock.types.ts new file mode 100644 index 00000000..f972f963 --- /dev/null +++ b/src/components/StockChart/types/stock.types.ts @@ -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[]; +} From 7b2f5a18bc7772d9b44cdf7f924c87f4ada6735f Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Sat, 22 Nov 2025 23:10:50 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:StockChart=20=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E7=BB=9F=E4=B8=80=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/StockChart/config/index.ts | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/components/StockChart/config/index.ts diff --git a/src/components/StockChart/config/index.ts b/src/components/StockChart/config/index.ts new file mode 100644 index 00000000..2b2b9e1c --- /dev/null +++ b/src/components/StockChart/config/index.ts @@ -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'; From c391c4c98098b2d8f7f03fb11fafbeb6b7ff347d Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Sat, 22 Nov 2025 23:12:47 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=E7=8E=B0=E5=9C=A8=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E3=80=82=E9=A6=96?= =?UTF-8?q?=E5=85=88=E8=AE=A9=E6=88=91=E6=A3=80=E6=9F=A5=E7=8E=B0=E6=9C=89?= =?UTF-8?q?=E7=9A=84=E4=B8=BB=E9=A2=98=E9=85=8D=E7=BD=AE=EF=BC=8C=E4=BB=A5?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=E6=A0=B7=E5=BC=8F=E4=B8=80=E8=87=B4=E6=80=A7?= =?UTF-8?q?=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StockChart/config/chartConfig.ts | 205 ++++++++++++ src/components/StockChart/utils/chartUtils.ts | 295 +++++++++++++++++ .../StockChart/utils/dataAdapter.ts | 257 +++++++++++++++ .../StockChart/utils/eventMarkerUtils.ts | 305 ++++++++++++++++++ src/components/StockChart/utils/index.ts | 48 +++ 5 files changed, 1110 insertions(+) create mode 100644 src/components/StockChart/config/chartConfig.ts create mode 100644 src/components/StockChart/utils/chartUtils.ts create mode 100644 src/components/StockChart/utils/dataAdapter.ts create mode 100644 src/components/StockChart/utils/eventMarkerUtils.ts create mode 100644 src/components/StockChart/utils/index.ts 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'; From bcd67ed4108622a97b3d6e580e25114aac3606ad Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Sat, 22 Nov 2025 23:14:16 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=20Hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/StockChart/hooks/index.ts | 15 ++ .../StockChart/hooks/useEventMarker.ts | 209 +++++++++++++++++ .../StockChart/hooks/useKLineChart.ts | 173 ++++++++++++++ .../StockChart/hooks/useKLineData.ts | 222 ++++++++++++++++++ 4 files changed, 619 insertions(+) create mode 100644 src/components/StockChart/hooks/index.ts create mode 100644 src/components/StockChart/hooks/useEventMarker.ts create mode 100644 src/components/StockChart/hooks/useKLineChart.ts create mode 100644 src/components/StockChart/hooks/useKLineData.ts diff --git a/src/components/StockChart/hooks/index.ts b/src/components/StockChart/hooks/index.ts new file mode 100644 index 00000000..8dbc9dc7 --- /dev/null +++ b/src/components/StockChart/hooks/index.ts @@ -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'; diff --git a/src/components/StockChart/hooks/useEventMarker.ts b/src/components/StockChart/hooks/useEventMarker.ts new file mode 100644 index 00000000..76b0b734 --- /dev/null +++ b/src/components/StockChart/hooks/useEventMarker.ts @@ -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(null); + const [markerId, setMarkerId] = useState(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, + }; +}; diff --git a/src/components/StockChart/hooks/useKLineChart.ts b/src/components/StockChart/hooks/useKLineChart.ts new file mode 100644 index 00000000..6be14294 --- /dev/null +++ b/src/components/StockChart/hooks/useKLineChart.ts @@ -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; + /** 是否已初始化 */ + 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(null); + const chartInstanceRef = useRef(null); + const [isInitialized, setIsInitialized] = useState(false); + const [error, setError] = useState(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, + }; +}; diff --git a/src/components/StockChart/hooks/useKLineData.ts b/src/components/StockChart/hooks/useKLineData.ts new file mode 100644 index 00000000..e3244f02 --- /dev/null +++ b/src/components/StockChart/hooks/useKLineData.ts @@ -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; + /** 更新数据 */ + 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([]); + const [rawData, setRawData] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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, + }; +}; From f361cb55f4d6b5b0c019d097f138858ccada9ef6 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Sat, 22 Nov 2025 23:14:59 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=E7=8E=B0=E5=9C=A8=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E4=B8=BB=E7=BB=84=E4=BB=B6=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StockChart/StockChartKLineModal.tsx | 287 +++++++++++++++++ .../StockChart/config/klineTheme.ts | 298 ++++++++++++++++++ src/components/StockChart/types/index.ts | 25 ++ 3 files changed, 610 insertions(+) create mode 100644 src/components/StockChart/StockChartKLineModal.tsx create mode 100644 src/components/StockChart/config/klineTheme.ts create mode 100644 src/components/StockChart/types/index.ts diff --git a/src/components/StockChart/StockChartKLineModal.tsx b/src/components/StockChart/StockChartKLineModal.tsx new file mode 100644 index 00000000..1db9a9bb --- /dev/null +++ b/src/components/StockChart/StockChartKLineModal.tsx @@ -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 = ({ + visible, + onClose, + stock, + eventTime, + eventTitle, +}) => { + // ==================== 状态管理 ==================== + + /** 图表类型(分时图/日K线) */ + const [chartType, setChartType] = useState('daily'); + + /** 选中的副图指标 */ + const [selectedIndicators, setSelectedIndicators] = useState( + 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 ( + + {/* 工具栏 */} + + + {/* 图表类型切换 */} + + + 分时图 + + + 日K线 + + + + {/* 副图指标选择 */} + + + {/* 刷新按钮 */} + + + + + {/* 错误提示 */} + {hasError && ( + + )} + + {/* 图表容器 */} + + {/* 加载遮罩 */} + {showLoading && ( + + + + )} + + {/* KLineChart 容器 */} +
+ + + {/* 数据信息(调试用,生产环境可移除) */} + {process.env.NODE_ENV === 'development' && ( + + + 数据点数: {data.length} + 事件标记: {marker ? marker.label : '无'} + 图表ID: {chart?.id || '未初始化'} + + + )} + + ); +}; + +export default StockChartKLineModal; diff --git a/src/components/StockChart/config/klineTheme.ts b/src/components/StockChart/config/klineTheme.ts new file mode 100644 index 00000000..05c70912 --- /dev/null +++ b/src/components/StockChart/config/klineTheme.ts @@ -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 = { + 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 = { + ...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 => { + return colorMode === 'dark' ? darkTheme : lightTheme; +}; diff --git a/src/components/StockChart/types/index.ts b/src/components/StockChart/types/index.ts new file mode 100644 index 00000000..204a38d6 --- /dev/null +++ b/src/components/StockChart/types/index.ts @@ -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';