├── screenshot01.png
├── screenshot02.png
├── screenshot03.png
├── LICENSE
├── README.md
├── index.html
├── screenshot-match.html
├── stocks-grid.html
├── fixed-bar-example.html
├── real-time-demo.html
└── fenshi.js
/screenshot01.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reference/FenshiChart/main/screenshot01.png
--------------------------------------------------------------------------------
/screenshot02.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reference/FenshiChart/main/screenshot02.png
--------------------------------------------------------------------------------
/screenshot03.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reference/FenshiChart/main/screenshot03.png
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Ban
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # FenshiChart - 实时分时图表库
2 |
3 | FenshiChart 是一个轻量级的 JavaScript 图表库,专为显示股票、加密货币等金融产品的实时行情数据而设计。它支持秒级别更新,提供流畅的数据可视化体验,适合构建专业的交易和金融数据分析应用。
4 |
5 | 
6 | 
7 | 
8 | ## 特点
9 |
10 | - 实时渲染,支持秒级数据更新
11 | - 显示价格走势线和成交量柱状图
12 | - 支持均价线显示(基于成交量加权平均)
13 | - 专为黑暗主题设计,易于阅读
14 | - 自动适应容器大小,支持响应式布局
15 | - 轻量级,无第三方依赖
16 | - 可定制的颜色、线条和显示选项
17 | - 中国A股风格:采用同花顺配色方案(涨为红色、跌为绿色)
18 | - 高级横向滚动:支持鼠标拖拽、触摸滑动和滚轮操作
19 | - 精确十字线:显示虚线十字线和数据点高亮
20 | - 数据提示框:鼠标悬停时显示详细数据信息
21 | - 右侧价格标签:显示涨跌幅数据
22 | - 移动端优化:自动适配移动设备体验
23 |
24 | ## 快速开始
25 |
26 | ### 基本用法
27 |
28 | 1. 在你的项目中引入 `fenshi.js`:
29 |
30 | ```html
31 |
32 | ```
33 |
34 | 2. 创建一个用于图表的容器:
35 |
36 | ```html
37 |
38 | ```
39 |
40 | 3. 初始化图表并添加数据:
41 |
42 | ```javascript
43 | // 初始化图表
44 | const chart = new FenshiChart('chart-container', {
45 | height: 400,
46 | backgroundColor: '#0e1117',
47 | lineColor: '#36a2eb',
48 | averageColor: '#ffcd56',
49 | upBarColor: '#F44336', // 涨为红色(同花顺风格)
50 | downBarColor: '#4CAF50', // 跌为绿色(同花顺风格)
51 | showAverage: true,
52 | enableScroll: true, // 启用横向滚动
53 | showRightPrice: true // 显示右侧价格标签
54 | });
55 |
56 | // 添加初始数据
57 | const initialData = [
58 | { time: new Date('2023-05-15T09:30:00'), price: 10.50, volume: 2500 },
59 | { time: new Date('2023-05-15T09:30:01'), price: 10.48, volume: 1200 },
60 | // 更多数据点...
61 | ];
62 | chart.setInitialData(initialData);
63 |
64 | // 添加实时数据点
65 | function addNewDataPoint() {
66 | const newData = {
67 | time: new Date(),
68 | price: 10.52 + (Math.random() - 0.5) * 0.1,
69 | volume: Math.floor(Math.random() * 2000) + 500
70 | };
71 | chart.addData(newData);
72 | }
73 |
74 | // 模拟实时数据更新(每秒)
75 | setInterval(addNewDataPoint, 1000);
76 | ```
77 |
78 | ### 从 WebSocket 获取实时数据
79 |
80 | ```javascript
81 | const chart = new FenshiChart('chart-container');
82 |
83 | // 连接WebSocket
84 | const socket = new WebSocket('wss://your-data-source.com/ws');
85 |
86 | // 处理初始数据
87 | socket.addEventListener('open', () => {
88 | socket.send(JSON.stringify({
89 | action: 'subscribe',
90 | symbol: 'AAPL'
91 | }));
92 | });
93 |
94 | // 处理实时数据更新
95 | socket.addEventListener('message', (event) => {
96 | const data = JSON.parse(event.data);
97 |
98 | // 如果是初始历史数据
99 | if (data.type === 'history') {
100 | const formattedData = data.data.map(item => ({
101 | time: new Date(item.timestamp),
102 | price: parseFloat(item.price),
103 | volume: parseInt(item.volume)
104 | }));
105 | chart.setInitialData(formattedData);
106 | }
107 | // 如果是实时更新
108 | else if (data.type === 'update') {
109 | chart.addData({
110 | time: new Date(data.timestamp),
111 | price: parseFloat(data.price),
112 | volume: parseInt(data.volume)
113 | });
114 | }
115 | });
116 | ```
117 |
118 | ## 配置选项
119 |
120 | 创建图表时可以传递以下配置选项:
121 |
122 | ```javascript
123 | const options = {
124 | // 尺寸
125 | width: 800, // 图表宽度(可选,默认使用容器宽度)
126 | height: 400, // 图表高度
127 |
128 | // 内边距
129 | padding: { // 内边距设置
130 | top: 20,
131 | right: 50,
132 | bottom: 60,
133 | left: 50
134 | },
135 |
136 | // 颜色
137 | backgroundColor: '#0e1117', // 背景色
138 | lineColor: '#36a2eb', // 价格线颜色
139 | averageColor: '#ffcd56', // 均价线颜色
140 | gridColor: '#333', // 网格线颜色
141 | textColor: '#ccc', // 文字颜色
142 | upBarColor: '#F44336', // 上涨柱状图颜色(红色)
143 | downBarColor: '#4CAF50', // 下跌柱状图颜色(绿色)
144 | crosshairColor: 'rgba(255,255,255,0.5)', // 十字线颜色
145 |
146 | // 功能设置
147 | showAverage: true, // 是否显示均价线
148 | animation: true, // 是否启用动画
149 | timeFormat: 'HH:mm:ss', // 时间格式
150 | maxDataPoints: 300, // 最大数据点数量
151 | gridLines: 5, // 水平网格线数量
152 | barWidth: 2, // 柱子宽度(固定模式)
153 | barSpacing: 1, // 柱子间距(固定模式)
154 |
155 | // 新增功能设置
156 | enableScroll: true, // 启用水平滚动
157 | showRightPrice: true, // 显示右侧价格标签
158 | showCrosshair: true, // 显示鼠标十字线
159 | tooltipEnabled: true, // 显示鼠标悬停提示
160 | coordinateType: 'auto', // 坐标轴类型: 'auto'、'percent'、'limit'
161 | limitPercentage: 10, // 涨跌幅限制(用于'limit'坐标系)
162 | initialPrice: 100, // 初始参考价格,特别适用于'limit'坐标模式
163 | infoBarEnabled: true, // 显示顶部价格信息面板
164 | autoHideCrosshairOnMobile: true // 在移动设备上自动隐藏十字线
165 | };
166 |
167 | const chart = new FenshiChart('chart-container', options);
168 | ```
169 |
170 | ## API 参考
171 |
172 | ### 创建图表
173 |
174 | ```javascript
175 | const chart = new FenshiChart(container, options);
176 | ```
177 |
178 | - `container`:字符串(元素ID)或DOM元素引用
179 | - `options`:配置选项对象(见上文)
180 |
181 | ### 设置初始数据
182 |
183 | ```javascript
184 | chart.setInitialData(data);
185 | ```
186 |
187 | - `data`:数据点数组,每个数据点包含 `time`、`price` 和 `volume` 属性
188 |
189 | ### 添加新数据点
190 |
191 | ```javascript
192 | chart.addData(dataPoint);
193 | ```
194 |
195 | - `dataPoint`:包含 `time`、`price` 和 `volume` 属性的对象
196 |
197 | #### 指定买卖方向
198 |
199 | 您可以通过设置数据点的 `direction` 属性来指定成交量柱子的颜色:
200 |
201 | ```javascript
202 | chart.addData({
203 | time: new Date(),
204 | price: 10.52,
205 | volume: 1500,
206 | direction: 'buy' // 'buy'、'sell' 或 'neutral'
207 | });
208 | ```
209 |
210 | 买卖方向对应的颜色:
211 | - `buy`: 红色(买入)
212 | - `sell`: 绿色(卖出)
213 | - `neutral`: 根据主题决定,暗色主题为白色,亮色主题为棕黄色
214 | - 未指定时默认为 `neutral`
215 |
216 | ### 调整大小
217 |
218 | ```javascript
219 | chart.resize(width, height);
220 | ```
221 |
222 | - `width`:新宽度(可选,默认使用容器宽度)
223 | - `height`:新高度(可选,默认为400)
224 |
225 | ### 更新配置
226 |
227 | ```javascript
228 | chart.updateOptions(newOptions);
229 | ```
230 |
231 | - `newOptions`:要更新的选项对象
232 |
233 | ### 设置滚动位置
234 |
235 | ```javascript
236 | chart.setScrollPosition(position);
237 | ```
238 |
239 | - `position`:滚动位置(0-1之间的小数)
240 |
241 | ### 设置初始参考价格
242 |
243 | ```javascript
244 | chart.setInitialPrice(price);
245 | ```
246 |
247 | - `price`:参考价格(数值类型)
248 |
249 | 这个方法特别适用于涨停板坐标类型('limit'),允许设置用于计算涨跌停价格的参考价格。当图表数据尚未加载或需要使用特定的价格作为涨跌计算基准时非常有用。设置初始价格后,涨停和跌停价格将根据这个价格和limitPercentage参数计算。
250 |
251 | ### 生成模拟数据(用于测试)
252 |
253 | ```javascript
254 | const mockData = FenshiChart.generateMockData(numPoints, startTime, startPrice);
255 | ```
256 |
257 | - `numPoints`:要生成的数据点数量(默认100)
258 | - `startTime`:起始时间(默认为当前时间)
259 | - `startPrice`:起始价格(默认为100)
260 |
261 | ## 交互特性
262 |
263 | ### 横向滚动
264 |
265 | 支持多种方式浏览大量历史数据:
266 | - 鼠标拖拽:在图表上按住鼠标左键并拖动
267 | - 触摸滑动:在移动设备上左右滑动
268 | - 滚轮滚动:使用鼠标滚轮横向滚动
269 | - 滚动条控制:通过滑块控制精确定位
270 |
271 | ### 数据点高亮和十字线
272 |
273 | 当鼠标悬停在图表上时,会显示十字线帮助定位价格和时间点。十字线会跟随鼠标移动,对应的数据点会高亮显示,并在轴上显示当前价格和时间信息。
274 |
275 | ### 右侧价格标签
276 |
277 | 右侧显示以开盘价为基准的价格变化标签,上涨和下跌使用不同颜色区分,帮助快速识别价格波动幅度。
278 |
279 | ### 数据提示框
280 |
281 | 鼠标悬停时会显示一个提示框,包含当前数据点的详细信息:
282 | - 时间:当前数据点的时间(时:分:秒)
283 | - 价格:当前价格
284 | - 均价:成交量加权平均价
285 | - 涨跌:相对于开盘价的变化值和百分比
286 | - 成交量:当前成交量
287 | - 金额:交易金额(价格×成交量)
288 |
289 | ### 移动端优化
290 |
291 | 在移动设备上访问时:
292 | - 自动禁用十字线和提示框(可通过配置更改)
293 | - 优化触摸交互体验
294 | - 响应式布局自动调整
295 |
296 | ## 演示
297 |
298 | 本库包含多个演示文件:
299 |
300 | 1. `index.html` - 基本演示,展示了如何使用 FenshiChart 及其基本功能
301 | 2. `real-time-demo.html` - 模拟真实交易平台的实时数据和 UI 界面
302 | 3. `fixed-bar-example.html` - 展示固定柱宽模式和新增功能的综合演示
303 | 4. `screenshot-match.html` - 使用固定数据生成与参考图像匹配的分时图
304 | 5. `stocks-grid.html` - 多股票分时图网格展示,类似于市场总览
305 |
306 | ## 浏览器兼容性
307 |
308 | FenshiChart 适用于所有现代浏览器,包括:
309 |
310 | - Chrome 60+
311 | - Firefox 60+
312 | - Safari 12+
313 | - Edge 16+
314 |
315 | ## 许可证
316 |
317 | MIT
318 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | FenshiChart - JavaScript分时图表库
7 |
105 |
106 |
107 |
108 |
112 |
113 |
114 |
115 |
116 |
117 | 添加数据点
118 | 开始模拟
119 | 重置图表
120 |
121 |
122 |
123 |
144 |
145 |
146 | © 2023 FenshiChart | GitHub
147 |
148 |
149 |
150 |
151 |
273 |
274 |
--------------------------------------------------------------------------------
/screenshot-match.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 分时图表 - 截图匹配模式
7 |
115 |
116 |
117 |
118 |
121 |
122 |
123 | 此模式使用固定的数据集来生成与参考图像相似的分时图表,便于进行可视化比较和调试。
124 |
125 |
126 |
127 |
128 |
129 | 重置图表
130 | 显示/隐藏均线
131 |
132 |
133 |
134 |
135 | 显示右侧涨跌幅
136 |
137 |
138 |
139 | 柱子宽度:
140 |
141 | 3px
142 |
143 |
144 |
145 | 柱子间距:
146 |
147 | 1px
148 |
149 |
150 |
151 |
参考图像
152 |
这是一个标准的A股分时图样例,用于比较我们的渲染效果
153 |
154 |
155 |
156 |
157 |
158 |
283 |
284 |
--------------------------------------------------------------------------------
/stocks-grid.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 超级盘口
7 |
263 |
264 |
265 |
266 | 超级盘口
267 |
279 |
280 |
281 |
282 | 载入中...
283 |
284 |
285 |
286 |
287 |
288 |
289 |
576 |
577 |
578 |
--------------------------------------------------------------------------------
/fixed-bar-example.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | FenshiChart - 固定柱宽示例
7 |
279 |
280 |
281 |
282 |
286 |
294 |
295 |
312 |
313 |
314 |
315 |
316 |
317 | ◀
318 |
319 |
320 |
321 | ▶
322 |
323 | 100%
324 |
325 |
326 |
327 |
图表设置
328 |
329 |
330 |
331 |
图表功能
332 |
333 |
334 |
335 | 启用横向滚动
336 |
337 |
338 |
339 |
340 | 显示右侧涨跌幅
341 |
342 |
343 |
344 |
345 | 显示十字线
346 |
347 |
348 |
349 |
350 | 显示鼠标悬停提示
351 |
352 |
353 |
354 |
355 | 显示信息面板
356 |
357 |
358 |
坐标类型
359 |
360 |
361 | 普通坐标
362 | 满占坐标
363 | 涨停板坐标
364 |
365 |
366 |
367 | 涨跌停百分比:
368 |
369 | 10%
370 |
371 |
372 |
373 | 初始参考价格:
374 |
375 | 设置
376 |
377 |
378 |
379 |
380 |
柱图设置
381 |
382 |
383 | 柱子宽度:
384 |
385 | 2px
386 |
387 |
388 |
389 | 柱子间距:
390 |
391 | 1px
392 |
393 |
394 |
395 |
396 |
397 |
398 | 添加数据点
399 | 模拟实时数据
400 | 重置
401 |
402 |
403 |
404 |
数据量控制
405 |
406 |
407 | 少量数据
408 | 中量数据
409 | 大量数据
410 |
411 |
412 |
413 | 数据点数量:
414 |
415 | 100
416 |
417 |
418 |
419 |
420 |
421 |
829 |
830 |
--------------------------------------------------------------------------------
/real-time-demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 实时分时图表 - Real-time FenshiChart Demo
7 |
200 |
201 |
202 |
203 |
210 |
211 |
247 |
248 |
249 |
最后更新: --:--:--
250 |
数据点: 0
251 |
252 |
253 |
254 |
255 |
821 |
822 |
--------------------------------------------------------------------------------
/fenshi.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Fenshi.js - Real-time stock chart library
3 | * A lightweight library for rendering real-time stock data with second-level updates
4 | *
5 | * @author Scott Ban
6 | */
7 |
8 | class FenshiChart {
9 | constructor(container, options = {}) {
10 | this.container = typeof container === 'string' ? document.getElementById(container) : container;
11 | if (!this.container) {
12 | throw new Error('Container element not found');
13 | }
14 |
15 | // 默认主题颜色配置
16 | const themes = {
17 | dark: {
18 | line: '#36a2eb',
19 | average: '#ffcd56',
20 | grid: '#333',
21 | text: '#ccc',
22 | background: '#0e1117',
23 | upBar: '#F44336',
24 | downBar: '#4CAF50',
25 | crosshair: 'rgba(255, 255, 255, 0.5)'
26 | },
27 | light: {
28 | line: '#2196F3',
29 | average: '#FF9800',
30 | grid: '#e0e0e0',
31 | text: '#333',
32 | background: '#ffffff',
33 | upBar: '#F44336',
34 | downBar: '#4CAF50',
35 | crosshair: 'rgba(0, 0, 0, 0.3)'
36 | }
37 | };
38 |
39 | // 获取当前主题
40 | const currentTheme = options.theme || 'dark';
41 | const themeColors = themes[currentTheme] || themes.dark;
42 |
43 | // Default options
44 | this.options = {
45 | width: options.width || this.container.clientWidth || 800,
46 | height: options.height || 400,
47 | padding: options.padding || { top: 20, right: 50, bottom: 60, left: 50 },
48 | colors: {
49 | line: options.lineColor || themeColors.line,
50 | average: options.averageColor || themeColors.average, // 均价线颜色
51 | grid: options.gridColor || themeColors.grid,
52 | text: options.textColor || themeColors.text,
53 | background: options.backgroundColor || themeColors.background,
54 | upBar: options.upBarColor || themeColors.upBar, // 涨为红色
55 | downBar: options.downBarColor || themeColors.downBar, // 跌为绿色
56 | crosshair: options.crosshairColor || themeColors.crosshair // 十字线颜色
57 | },
58 | theme: currentTheme, // 保存当前主题
59 | themes: themes, // 保存所有主题配置
60 | showAverage: options.showAverage !== undefined ? options.showAverage : true,
61 | averagePeriod: options.averagePeriod || 20, // 不再用于均价线计算
62 | animation: options.animation !== undefined ? options.animation : true,
63 | timeFormat: options.timeFormat || 'HH:mm:ss',
64 | maxDataPoints: options.maxDataPoints || 300,
65 | gridLines: options.gridLines || 5,
66 | barWidth: options.barWidth || 2, // 固定柱子宽度为2px
67 | barSpacing: options.barSpacing || 1, // 柱子间距固定为1px
68 | rightOffset: options.rightOffset || 50, // 右侧留白,确保最新数据不会太靠边
69 | enableScroll: options.enableScroll !== undefined ? options.enableScroll : true,
70 | showRightPrice: options.showRightPrice !== undefined ? options.showRightPrice : true,
71 | scrollPosition: 1.0, // 滚动位置,1.0表示最新数据(右侧),0.0表示最早数据(左侧)
72 | showCrosshair: options.showCrosshair !== undefined ? options.showCrosshair : true, // 显示十字线
73 | tooltipEnabled: options.tooltipEnabled !== undefined ? options.tooltipEnabled : true, // 显示提示框
74 | coordinateType: options.coordinateType || 'normal', // 坐标类型:'normal', 'full', 'limit'
75 | limitPercentage: options.limitPercentage || 10, // 涨跌停板百分比,默认10%
76 | timeAxisHeight: options.timeAxisHeight || 30, // 时间轴高度
77 | maxTimeLabels: options.maxTimeLabels || 8, // 时间轴上最多显示的标签数量
78 | infoBarEnabled: options.infoBarEnabled !== undefined ? options.infoBarEnabled : true, // 控制信息面板显示
79 | initialPrice: options.initialPrice !== undefined ? parseFloat(options.initialPrice) : null, // 初始价格设置,方便涨停板模式参考
80 | };
81 |
82 | // Data
83 | this.data = [];
84 | this.averageData = []; // 均价线数据
85 | this.volumeData = [];
86 |
87 | // 用于计算均价线的累计数据
88 | this.cumulativeVolumeData = [];
89 | this.cumulativeAmountData = [];
90 |
91 | // State
92 | this.yRange = { min: 0, max: 0 };
93 | this.xRange = { min: 0, max: 0 };
94 | this.volumeRange = { min: 0, max: 0 };
95 | this.priceInfo = {
96 | current: 0,
97 | open: 0,
98 | high: 0,
99 | low: 0,
100 | change: 0,
101 | changePercent: 0,
102 | avgPrice: 0, // 当前均价
103 | };
104 |
105 | // 滚动相关变量
106 | this.isDragging = false;
107 | this.dragStartX = 0;
108 | this.scrollOffsetX = 0;
109 | this.prevTouchX = 0;
110 | this.touchIdentifier = null;
111 |
112 | // 鼠标悬停相关
113 | this.mousePosition = { x: 0, y: 0 };
114 | this.isMouseOver = false;
115 | this.hoveredDataIndex = -1;
116 | this.tooltip = null;
117 |
118 | this.init();
119 | }
120 |
121 | init() {
122 | // 创建主画布
123 | this.canvas = document.createElement('canvas');
124 | this.canvas.width = this.options.width;
125 | this.canvas.height = this.options.height;
126 | this.canvas.style.display = 'block';
127 | this.canvas.style.width = '100%';
128 | this.canvas.style.maxWidth = '100%';
129 | this.ctx = this.canvas.getContext('2d');
130 |
131 | // 创建成交量画布
132 | this.volumeHeight = Math.floor(this.options.height * 0.2);
133 | this.volumeCanvas = document.createElement('canvas');
134 | this.volumeCanvas.width = this.options.width;
135 | this.volumeCanvas.height = this.volumeHeight;
136 | this.volumeCanvas.style.display = 'block';
137 | this.volumeCanvas.style.marginTop = '5px';
138 | this.volumeCanvas.style.width = '100%';
139 | this.volumeCanvas.style.maxWidth = '100%';
140 | this.volumeCtx = this.volumeCanvas.getContext('2d');
141 |
142 | // 创建时间轴画布
143 | this.timeAxisCanvas = document.createElement('canvas');
144 | this.timeAxisCanvas.width = this.options.width;
145 | this.timeAxisCanvas.height = this.options.timeAxisHeight;
146 | this.timeAxisCanvas.style.display = 'block';
147 | this.timeAxisCanvas.style.marginTop = '1px';
148 | this.timeAxisCanvas.style.width = '100%';
149 | this.timeAxisCanvas.style.maxWidth = '100%';
150 | this.timeAxisCtx = this.timeAxisCanvas.getContext('2d');
151 |
152 | // 创建信息面板
153 | this.infoPanel = document.createElement('div');
154 | this.infoPanel.style.marginBottom = '10px'; // 改为底部边距
155 | this.infoPanel.style.fontSize = '12px';
156 | this.infoPanel.style.lineHeight = '1.4';
157 | this.infoPanel.style.color = this.options.colors.text;
158 |
159 | // 创建容器
160 | this.chartContainer = document.createElement('div');
161 | this.chartContainer.style.position = 'relative';
162 | this.chartContainer.style.width = '100%';
163 | this.chartContainer.style.boxSizing = 'border-box';
164 | this.chartContainer.style.overflow = 'hidden';
165 | this.chartContainer.appendChild(this.infoPanel); // 信息面板放在顶部
166 | this.chartContainer.appendChild(this.canvas);
167 | this.chartContainer.appendChild(this.volumeCanvas);
168 | this.chartContainer.appendChild(this.timeAxisCanvas); // 时间轴放在最下方
169 |
170 | // 主题相关样式
171 | this.applyThemeStyles();
172 |
173 | // 创建工具提示元素
174 | this.createTooltip();
175 |
176 | this.container.appendChild(this.chartContainer);
177 |
178 | // 设置信息面板的可见性
179 | this.infoPanel.style.display = this.options.infoBarEnabled !== false ? 'block' : 'none';
180 |
181 | // 监听窗口大小变化
182 | window.addEventListener('resize', this.handleResize.bind(this));
183 |
184 | // 初始化尺寸
185 | this.handleResize();
186 |
187 | // 如果启用滚动,设置滚动事件监听
188 | if (this.options.enableScroll) {
189 | this.setupScrollEvents();
190 | }
191 |
192 | // 添加鼠标事件监听器
193 | this.setupMouseEvents();
194 | }
195 |
196 | // 处理窗口大小变化
197 | handleResize() {
198 | // 获取容器真实宽度
199 | const containerWidth = this.container.clientWidth;
200 |
201 | // 如果宽度变化了,需要更新canvas大小
202 | if (containerWidth !== this.options.width) {
203 | this.resize(containerWidth);
204 | }
205 | }
206 |
207 | // 修改resize方法以适应新的时间轴
208 | resize(width, height) {
209 | const newWidth = width || this.container.clientWidth;
210 | const newHeight = height || this.options.height;
211 |
212 | this.options.width = newWidth;
213 | this.options.height = newHeight;
214 |
215 | // 更新canvas尺寸
216 | this.canvas.width = newWidth;
217 | this.canvas.height = newHeight;
218 |
219 | this.volumeHeight = Math.floor(newHeight * 0.2);
220 | this.volumeCanvas.width = newWidth;
221 | this.volumeCanvas.height = this.volumeHeight;
222 |
223 | this.timeAxisCanvas.width = newWidth;
224 | this.timeAxisCanvas.height = this.options.timeAxisHeight;
225 |
226 | // 重新计算滚动位置
227 | const chartWidth = newWidth - this.options.padding.left - this.options.padding.right;
228 | const totalWidth = this.getFixedWidthChartWidth();
229 |
230 | if (totalWidth > chartWidth) {
231 | const maxScroll = totalWidth - chartWidth;
232 | this.scrollOffsetX = maxScroll * (1 - this.options.scrollPosition);
233 | } else {
234 | this.scrollOffsetX = 0;
235 | }
236 |
237 | // 重新渲染
238 | this.render();
239 | }
240 |
241 | // 应用主题相关样式
242 | applyThemeStyles() {
243 | this.chartContainer.style.backgroundColor = this.options.colors.background;
244 | this.infoPanel.style.color = this.options.colors.text;
245 | this.timeAxisCanvas.style.backgroundColor = this.options.colors.background;
246 |
247 | if (this.tooltip) {
248 | // 亮色主题需要更深的背景色以确保可读性
249 | if (this.options.theme === 'light') {
250 | this.tooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
251 | this.tooltip.style.color = '#fff';
252 | } else {
253 | this.tooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
254 | this.tooltip.style.color = '#fff';
255 | }
256 | }
257 | }
258 |
259 | // 切换主题
260 | switchTheme(theme) {
261 | if (theme !== 'dark' && theme !== 'light') {
262 | console.warn('Invalid theme. Using dark theme as default.');
263 | theme = 'dark';
264 | }
265 |
266 | // 更新主题
267 | this.options.theme = theme;
268 |
269 | // 获取主题颜色
270 | const themeColors = this.options.themes[theme];
271 |
272 | // 更新颜色配置
273 | this.options.colors = {
274 | ...this.options.colors,
275 | line: themeColors.line,
276 | average: themeColors.average,
277 | grid: themeColors.grid,
278 | text: themeColors.text,
279 | background: themeColors.background,
280 | crosshair: themeColors.crosshair
281 | // 不更新上涨/下跌颜色,保持红涨绿跌
282 | };
283 |
284 | // 应用主题样式
285 | this.applyThemeStyles();
286 |
287 | // 重新渲染
288 | this.render();
289 | }
290 |
291 | // 设置滚动事件
292 | setupScrollEvents() {
293 | // 鼠标拖动
294 | this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
295 | document.addEventListener('mousemove', this.handleMouseMove.bind(this));
296 | document.addEventListener('mouseup', this.handleMouseUp.bind(this));
297 |
298 | // 为时间轴也添加拖动事件
299 | this.timeAxisCanvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
300 |
301 | // 触摸事件
302 | this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this));
303 | this.timeAxisCanvas.addEventListener('touchstart', this.handleTouchStart.bind(this));
304 | document.addEventListener('touchmove', this.handleTouchMove.bind(this));
305 | document.addEventListener('touchend', this.handleTouchEnd.bind(this));
306 | document.addEventListener('touchcancel', this.handleTouchEnd.bind(this));
307 |
308 | // 鼠标滚轮
309 | this.canvas.addEventListener('wheel', this.handleWheel.bind(this));
310 | this.volumeCanvas.addEventListener('wheel', this.handleWheel.bind(this));
311 | this.timeAxisCanvas.addEventListener('wheel', this.handleWheel.bind(this));
312 | }
313 |
314 | // 鼠标事件处理
315 | handleMouseDown(e) {
316 | this.isDragging = true;
317 | this.dragStartX = e.clientX;
318 | this.canvas.style.cursor = 'grabbing';
319 | e.preventDefault();
320 | }
321 |
322 | handleMouseMove(e) {
323 | // 保存鼠标位置
324 | const rect = this.canvas.getBoundingClientRect();
325 | this.mousePosition = {
326 | x: e.clientX - rect.left,
327 | y: e.clientY - rect.top
328 | };
329 |
330 | // 如果正在拖动,处理滚动逻辑
331 | if (this.isDragging) {
332 | const dx = e.clientX - this.dragStartX;
333 | this.dragStartX = e.clientX;
334 |
335 | this.updateScroll(dx);
336 | e.preventDefault();
337 | return; // 拖动时不显示十字线
338 | }
339 |
340 | // 如果没有拖动,则处理十字线
341 | this.updateHoveredDataIndex();
342 |
343 | // 仅当悬停状态改变时才重新渲染
344 | if (this.options.showCrosshair && this.hoveredDataIndex >= 0) {
345 | this.render();
346 |
347 | // 更新并显示提示框
348 | if (this.options.tooltipEnabled) {
349 | this.updateTooltip();
350 | }
351 | } else if (this.tooltip) {
352 | // 如果鼠标不在数据点上,隐藏提示框
353 | this.hideTooltip();
354 | this.render(); // 重绘清除十字线
355 | }
356 | }
357 |
358 | handleMouseUp(e) {
359 | this.isDragging = false;
360 | this.canvas.style.cursor = 'grab';
361 | }
362 |
363 | // 触摸事件处理
364 | handleTouchStart(e) {
365 | if (e.touches.length === 1) {
366 | const touch = e.touches[0];
367 | this.touchIdentifier = touch.identifier;
368 | this.prevTouchX = touch.clientX;
369 | e.preventDefault();
370 | }
371 | }
372 |
373 | handleTouchMove(e) {
374 | if (this.touchIdentifier === null) return;
375 |
376 | for (let i = 0; i < e.changedTouches.length; i++) {
377 | const touch = e.changedTouches[i];
378 | if (touch.identifier === this.touchIdentifier) {
379 | const dx = touch.clientX - this.prevTouchX;
380 | this.prevTouchX = touch.clientX;
381 | this.updateScroll(dx);
382 | e.preventDefault();
383 | break;
384 | }
385 | }
386 | }
387 |
388 | handleTouchEnd(e) {
389 | for (let i = 0; i < e.changedTouches.length; i++) {
390 | if (e.changedTouches[i].identifier === this.touchIdentifier) {
391 | this.touchIdentifier = null;
392 | break;
393 | }
394 | }
395 | }
396 |
397 | // 滚轮事件处理,确保时间轴也能被操作
398 | handleWheel(e) {
399 | // 只处理水平滚动或滚轮事件
400 | if (e.deltaX !== 0) {
401 | e.preventDefault();
402 | this.updateScroll(e.deltaX);
403 | } else if (e.deltaY !== 0 && (e.shiftKey || e.target === this.timeAxisCanvas)) {
404 | // 当按住Shift键时或直接在时间轴上滚动时,垂直滚动转为水平滚动
405 | e.preventDefault();
406 | this.updateScroll(e.deltaY);
407 | }
408 | // 其他情况不阻止默认行为,允许页面正常滚动
409 | }
410 |
411 | // 更新滚动位置
412 | updateScroll(dx) {
413 | const chartWidth = this.options.width - this.options.padding.left - this.options.padding.right;
414 | const totalWidth = this.getFixedWidthChartWidth();
415 |
416 | if (totalWidth <= chartWidth) return; // 如果数据很少,不需要滚动
417 |
418 | // 计算新的滚动偏移
419 | this.scrollOffsetX += dx;
420 |
421 | // 限制滚动范围
422 | const maxScroll = totalWidth - chartWidth;
423 | this.scrollOffsetX = Math.max(0, Math.min(this.scrollOffsetX, maxScroll));
424 |
425 | // 更新滚动位置百分比
426 | this.options.scrollPosition = 1 - (this.scrollOffsetX / maxScroll);
427 |
428 | // 重新渲染
429 | this.render();
430 | }
431 |
432 | // 设置滚动位置
433 | setScrollPosition(position) {
434 | // position: 0.0 - 1.0 (0 = 最左侧, 1 = 最右侧)
435 | position = Math.max(0, Math.min(1, position));
436 | this.options.scrollPosition = position;
437 |
438 | const chartWidth = this.options.width - this.options.padding.left - this.options.padding.right;
439 | const totalWidth = this.getFixedWidthChartWidth();
440 |
441 | if (totalWidth > chartWidth) {
442 | const maxScroll = totalWidth - chartWidth;
443 | this.scrollOffsetX = maxScroll * (1 - position);
444 | this.render();
445 | }
446 | }
447 |
448 | // Data handling methods
449 | setInitialData(data) {
450 | this.data = data.map(d => ({
451 | time: new Date(d.time),
452 | price: parseFloat(d.price),
453 | volume: parseInt(d.volume, 10),
454 | direction: d.direction || 'neutral' // 买卖方向,默认为neutral
455 | }));
456 |
457 | if (this.data.length > 0) {
458 | // Use initialPrice as open price if it's provided, otherwise use first data point price
459 | if (this.options.initialPrice !== null) {
460 | this.priceInfo.open = this.options.initialPrice;
461 | } else {
462 | this.priceInfo.open = this.data[0].price;
463 | }
464 |
465 | this.calculateStats();
466 | this.calculateAverages();
467 |
468 | // 重置滚动位置到最新数据
469 | this.scrollOffsetX = 0;
470 | this.options.scrollPosition = 1.0;
471 |
472 | this.render();
473 | }
474 | }
475 |
476 | addData(newData) {
477 | // Add new data point
478 | const dataPoint = {
479 | time: new Date(newData.time),
480 | price: parseFloat(newData.price),
481 | volume: parseInt(newData.volume, 10),
482 | direction: newData.direction || 'neutral' // 买卖方向,默认为neutral
483 | };
484 |
485 | this.data.push(dataPoint);
486 |
487 | // Limit data points
488 | if (this.data.length > this.options.maxDataPoints) {
489 | this.data.shift();
490 | }
491 |
492 | this.calculateStats();
493 | this.calculateAverages();
494 |
495 | // 如果滚动位置是最新的,则保持滚动位置在最右侧
496 | if (this.options.scrollPosition >= 0.99) {
497 | this.scrollOffsetX = 0;
498 | this.options.scrollPosition = 1.0;
499 | } else {
500 | // 否则维持当前的滚动偏移位置,但更新滚动位置百分比
501 | const chartWidth = this.options.width - this.options.padding.left - this.options.padding.right;
502 | const totalWidth = this.getFixedWidthChartWidth();
503 | const maxScroll = totalWidth - chartWidth;
504 |
505 | if (maxScroll > 0) {
506 | this.options.scrollPosition = 1 - (this.scrollOffsetX / maxScroll);
507 | }
508 | }
509 |
510 | this.render();
511 | }
512 |
513 | calculateStats() {
514 | if (this.data.length === 0) return;
515 |
516 | // Find min/max values
517 | this.priceInfo.current = this.data[this.data.length - 1].price;
518 | this.priceInfo.high = Math.max(...this.data.map(d => d.price));
519 | this.priceInfo.low = Math.min(...this.data.map(d => d.price));
520 | this.priceInfo.change = this.priceInfo.current - this.priceInfo.open;
521 | this.priceInfo.changePercent = (this.priceInfo.change / this.priceInfo.open) * 100;
522 |
523 | // 根据坐标类型计算价格范围
524 | this.calculatePriceRange();
525 |
526 | // Volume range
527 | this.volumeData = this.data.map(d => d.volume);
528 | this.volumeRange.max = Math.max(...this.volumeData);
529 | this.volumeRange.min = 0;
530 |
531 | // Time range
532 | this.xRange.min = this.data[0].time.getTime();
533 | this.xRange.max = this.data[this.data.length - 1].time.getTime();
534 |
535 | // Update info panel
536 | this.updateInfoPanel();
537 | }
538 |
539 | // 根据坐标类型计算价格范围
540 | calculatePriceRange() {
541 | const coordinateType = this.options.coordinateType;
542 |
543 | switch (coordinateType) {
544 | case 'normal': // 普通坐标 - 根据数据动态调整,带有一定padding
545 | const priceRange = this.priceInfo.high - this.priceInfo.low;
546 | const padding = priceRange * 0.1;
547 | this.yRange.min = this.priceInfo.low - padding;
548 | this.yRange.max = this.priceInfo.high + padding;
549 | break;
550 |
551 | case 'full': // 满占坐标 - 使用最大和最小值,没有padding
552 | this.yRange.min = this.priceInfo.low;
553 | this.yRange.max = this.priceInfo.high;
554 | break;
555 |
556 | case 'limit': // 涨停板坐标 - 基于初始价格或开盘价和涨跌停百分比
557 | const limitPercent = this.options.limitPercentage / 100;
558 | // 优先使用initialPrice作为基准价格计算涨跌停,如果未设置则使用开盘价
559 | const referencePrice = this.options.initialPrice !== null ? this.options.initialPrice : this.priceInfo.open;
560 | this.yRange.min = referencePrice * (1 - limitPercent);
561 | this.yRange.max = referencePrice * (1 + limitPercent);
562 | break;
563 |
564 | default:
565 | // 默认使用普通坐标
566 | const defaultRange = this.priceInfo.high - this.priceInfo.low;
567 | const defaultPadding = defaultRange * 0.1;
568 | this.yRange.min = this.priceInfo.low - defaultPadding;
569 | this.yRange.max = this.priceInfo.high + defaultPadding;
570 | }
571 |
572 | // 确保最小价格不为负数
573 | if (this.yRange.min < 0) {
574 | this.yRange.min = 0;
575 | }
576 | }
577 |
578 | calculateAverages() {
579 | if (!this.options.showAverage || this.data.length === 0) {
580 | this.averageData = [];
581 | return;
582 | }
583 |
584 | // 重置累计数据
585 | this.cumulativeVolumeData = [];
586 | this.cumulativeAmountData = [];
587 | this.averageData = [];
588 |
589 | let totalVolume = 0;
590 | let totalAmount = 0;
591 |
592 | // 计算每个点的累计成交量和成交金额,以及对应的均价
593 | for (let i = 0; i < this.data.length; i++) {
594 | const dataPoint = this.data[i];
595 | const volume = dataPoint.volume;
596 | const amount = dataPoint.price * volume;
597 |
598 | totalVolume += volume;
599 | totalAmount += amount;
600 |
601 | this.cumulativeVolumeData.push(totalVolume);
602 | this.cumulativeAmountData.push(totalAmount);
603 |
604 | // 计算均价 = 累计成交金额 / 累计成交量
605 | const avgPrice = totalVolume > 0 ? totalAmount / totalVolume : dataPoint.price;
606 | this.averageData.push(avgPrice);
607 | }
608 |
609 | // 更新当前均价
610 | if (this.data.length > 0) {
611 | this.priceInfo.avgPrice = this.averageData[this.averageData.length - 1];
612 | }
613 | }
614 |
615 | updateInfoPanel() {
616 | const changeSign = this.priceInfo.change >= 0 ? '+' : '';
617 | const changeColor = this.priceInfo.change >= 0 ? this.options.colors.upBar : this.options.colors.downBar;
618 |
619 | this.infoPanel.innerHTML = `
620 |
621 |
当前价: ${this.priceInfo.current.toFixed(2)}
622 |
涨跌幅: ${changeSign}${this.priceInfo.change.toFixed(2)} (${changeSign}${this.priceInfo.changePercent.toFixed(2)}%)
623 |
成交量: ${this.formatNumber(this.data[this.data.length - 1].volume)}
624 |
625 |
626 |
开盘价: ${this.priceInfo.open.toFixed(2)}
627 |
均价: ${this.priceInfo.avgPrice.toFixed(2)}
628 |
最高价: ${this.priceInfo.high.toFixed(2)}
629 |
最低价: ${this.priceInfo.low.toFixed(2)}
630 |
631 | `;
632 | }
633 |
634 | // Rendering methods
635 | render() {
636 | this.clear();
637 | this.drawGrid();
638 | this.drawTimeLabels(); // 绘制垂直网格线
639 | this.drawPriceChart();
640 | this.drawVolumeChart();
641 | this.drawTimeAxis(); // 绘制独立的时间轴
642 |
643 | if (this.options.showAverage && this.averageData.length > 0) {
644 | this.drawAverageLine();
645 | }
646 |
647 | // 绘制十字线
648 | if (this.options.showCrosshair && this.isMouseOver && this.hoveredDataIndex >= 0) {
649 | this.drawCrosshair();
650 | }
651 | }
652 |
653 | clear() {
654 | this.ctx.fillStyle = this.options.colors.background;
655 | this.ctx.fillRect(0, 0, this.options.width, this.options.height);
656 |
657 | this.volumeCtx.fillStyle = this.options.colors.background;
658 | this.volumeCtx.fillRect(0, 0, this.options.width, this.volumeHeight);
659 |
660 | this.timeAxisCtx.fillStyle = this.options.colors.background;
661 | this.timeAxisCtx.fillRect(0, 0, this.options.width, this.options.timeAxisHeight);
662 | }
663 |
664 | drawGrid() {
665 | const priceChartHeight = this.options.height - this.options.padding.top - this.options.padding.bottom;
666 | const chartWidth = this.options.width - this.options.padding.left - this.options.padding.right;
667 |
668 | // Draw horizontal grid lines for price chart
669 | this.ctx.strokeStyle = this.options.colors.grid;
670 | this.ctx.lineWidth = 0.5;
671 | this.ctx.setLineDash([5, 5]);
672 |
673 | const priceStep = (this.yRange.max - this.yRange.min) / this.options.gridLines;
674 |
675 | for (let i = 0; i <= this.options.gridLines; i++) {
676 | const price = this.yRange.max - (i * priceStep);
677 | const y = this.mapPriceToY(price);
678 |
679 | this.ctx.beginPath();
680 | this.ctx.moveTo(this.options.padding.left, y);
681 | this.ctx.lineTo(this.options.width - this.options.padding.right, y);
682 | this.ctx.stroke();
683 |
684 | // 左侧价格标签
685 | this.ctx.fillStyle = this.options.colors.text;
686 | this.ctx.font = '10px Arial';
687 | this.ctx.textAlign = 'right';
688 | this.ctx.textBaseline = 'middle';
689 | this.ctx.fillText(price.toFixed(2), this.options.padding.left - 5, y);
690 |
691 | // 添加右侧价格标签
692 | if (this.options.showRightPrice) {
693 | // 计算涨跌幅
694 | const changePercent = ((price - this.priceInfo.open) / this.priceInfo.open) * 100;
695 | const changeSign = changePercent >= 0 ? '+' : '';
696 | const changeText = `${changeSign}${changePercent.toFixed(2)}%`;
697 |
698 | // 设置颜色 - 符合同花顺配色方案
699 | const isPositive = changePercent >= 0;
700 | this.ctx.fillStyle = isPositive ? this.options.colors.upBar : this.options.colors.downBar;
701 |
702 | // 绘制右侧涨跌幅
703 | this.ctx.textAlign = 'left';
704 | this.ctx.fillText(changeText, this.options.width - this.options.padding.right + 5, y);
705 | }
706 | }
707 |
708 | // 涨停板坐标系额外标记:开盘价、涨停价、跌停价
709 | if (this.options.coordinateType === 'limit') {
710 | const limitPercent = this.options.limitPercentage / 100;
711 | const openPrice = this.priceInfo.open;
712 | const upperLimit = openPrice * (1 + limitPercent);
713 | const lowerLimit = openPrice * (1 - limitPercent);
714 |
715 | // 标记开盘价/参考价 - 虚线
716 | const openY = this.mapPriceToY(openPrice);
717 | this.ctx.beginPath();
718 | this.ctx.strokeStyle = this.options.colors.text;
719 | this.ctx.setLineDash([2, 2]);
720 | this.ctx.moveTo(this.options.padding.left, openY);
721 | this.ctx.lineTo(this.options.width - this.options.padding.right, openY);
722 | this.ctx.stroke();
723 |
724 | // 在左侧标记参考价文字
725 | this.ctx.fillStyle = this.options.colors.text;
726 | this.ctx.textAlign = 'right';
727 | // this.ctx.fillText(`参考价 ${openPrice.toFixed(2)}`, this.options.padding.left - 5, openY - 5);
728 |
729 | // 标记涨停价 - 红色虚线
730 | const upperY = this.mapPriceToY(upperLimit);
731 | this.ctx.beginPath();
732 | this.ctx.strokeStyle = this.options.colors.upBar;
733 | this.ctx.setLineDash([2, 2]);
734 | this.ctx.moveTo(this.options.padding.left, upperY);
735 | this.ctx.lineTo(this.options.width - this.options.padding.right, upperY);
736 | this.ctx.stroke();
737 |
738 | // 在左侧标记涨停文字
739 | this.ctx.fillStyle = this.options.colors.upBar;
740 | this.ctx.textAlign = 'right';
741 | // this.ctx.fillText(`涨停 ${upperLimit.toFixed(2)}`, this.options.padding.left - 5, upperY - 5);
742 |
743 | // 标记跌停价 - 绿色虚线
744 | const lowerY = this.mapPriceToY(lowerLimit);
745 | this.ctx.beginPath();
746 | this.ctx.strokeStyle = this.options.colors.downBar;
747 | this.ctx.setLineDash([2, 2]);
748 | this.ctx.moveTo(this.options.padding.left, lowerY);
749 | this.ctx.lineTo(this.options.width - this.options.padding.right, lowerY);
750 | this.ctx.stroke();
751 |
752 | // 在左侧标记跌停文字
753 | this.ctx.fillStyle = this.options.colors.downBar;
754 | this.ctx.textAlign = 'right';
755 | // this.ctx.fillText(`跌停 ${lowerLimit.toFixed(2)}`, this.options.padding.left - 5, lowerY + 10);
756 | }
757 |
758 | // Reset line dash
759 | this.ctx.setLineDash([]);
760 | }
761 |
762 | drawPriceChart() {
763 | if (this.data.length < 2) return;
764 |
765 | // 获取可见的数据点
766 | const visibleIndices = this.getVisibleDataIndices();
767 | const firstVisibleIndex = visibleIndices.first;
768 | const lastVisibleIndex = visibleIndices.last;
769 |
770 | this.ctx.strokeStyle = this.options.colors.line;
771 | this.ctx.lineWidth = 1.5;
772 | this.ctx.beginPath();
773 |
774 | let started = false;
775 |
776 | for (let i = firstVisibleIndex; i <= lastVisibleIndex; i++) {
777 | const x = this.getDataPointScreenX(i);
778 | const y = this.mapPriceToY(this.data[i].price);
779 |
780 | if (!started) {
781 | this.ctx.moveTo(x, y);
782 | started = true;
783 | } else {
784 | this.ctx.lineTo(x, y);
785 | }
786 | }
787 |
788 | this.ctx.stroke();
789 |
790 | // 绘制价格线下方的区域填充
791 | if (started) {
792 | const lastVisibleX = this.getDataPointScreenX(lastVisibleIndex);
793 | const firstVisibleX = this.getDataPointScreenX(firstVisibleIndex);
794 |
795 | this.ctx.lineTo(lastVisibleX, this.mapPriceToY(this.yRange.min));
796 | this.ctx.lineTo(firstVisibleX, this.mapPriceToY(this.yRange.min));
797 | this.ctx.closePath();
798 |
799 | const gradient = this.ctx.createLinearGradient(0, this.options.padding.top, 0, this.options.height - this.options.padding.bottom);
800 | gradient.addColorStop(0, 'rgba(54, 162, 235, 0.2)');
801 | gradient.addColorStop(1, 'rgba(54, 162, 235, 0)');
802 |
803 | this.ctx.fillStyle = gradient;
804 | this.ctx.fill();
805 | }
806 | }
807 |
808 | drawAverageLine() {
809 | if (this.data.length < 2) return;
810 |
811 | // 获取可见的数据点
812 | const visibleIndices = this.getVisibleDataIndices();
813 | const firstVisibleIndex = visibleIndices.first;
814 | const lastVisibleIndex = visibleIndices.last;
815 |
816 | this.ctx.strokeStyle = this.options.colors.average;
817 | this.ctx.lineWidth = 1.5;
818 | this.ctx.beginPath();
819 |
820 | let started = false;
821 |
822 | for (let i = firstVisibleIndex; i <= lastVisibleIndex; i++) {
823 | if (this.averageData[i] === null) continue;
824 |
825 | const x = this.getDataPointScreenX(i);
826 | const y = this.mapPriceToY(this.averageData[i]);
827 |
828 | if (!started) {
829 | this.ctx.moveTo(x, y);
830 | started = true;
831 | } else {
832 | this.ctx.lineTo(x, y);
833 | }
834 | }
835 |
836 | this.ctx.stroke();
837 | }
838 |
839 | drawVolumeChart() {
840 | if (this.data.length === 0) return;
841 |
842 | // 获取可见的数据点
843 | const visibleIndices = this.getVisibleDataIndices();
844 | const firstVisibleIndex = visibleIndices.first;
845 | const lastVisibleIndex = visibleIndices.last;
846 |
847 | const barWidth = this.options.barWidth;
848 |
849 | // 定义中性柱子颜色(根据主题)
850 | const neutralColor = this.options.theme === 'dark' ? 'white' : '#D4A017'; // 棕黄色
851 |
852 | for (let i = firstVisibleIndex; i <= lastVisibleIndex; i++) {
853 | const dataPoint = this.data[i];
854 |
855 | // 获取屏幕X坐标
856 | const x = this.getDataPointScreenX(i) - (barWidth / 2);
857 |
858 | // 根据买卖方向确定柱子颜色
859 | if (dataPoint.direction === 'buy') {
860 | this.volumeCtx.fillStyle = this.options.colors.upBar; // 买入用红色
861 | } else if (dataPoint.direction === 'sell') {
862 | this.volumeCtx.fillStyle = this.options.colors.downBar; // 卖出用绿色
863 | } else {
864 | // neutral或未指定,根据主题设置
865 | this.volumeCtx.fillStyle = neutralColor;
866 | }
867 |
868 | // 计算柱子高度
869 | const barHeight = this.mapVolumeToHeight(dataPoint.volume);
870 | const y = this.volumeHeight - barHeight;
871 |
872 | // 绘制成交量柱状图
873 | this.volumeCtx.fillRect(x, y, barWidth, barHeight);
874 | }
875 |
876 | // 成交量刻度显示
877 | this.volumeCtx.fillStyle = this.options.colors.text;
878 | this.volumeCtx.font = '10px Arial';
879 | this.volumeCtx.textAlign = 'left';
880 | this.volumeCtx.textBaseline = 'middle';
881 | this.volumeCtx.fillText(
882 | this.formatNumber(this.volumeRange.max),
883 | this.options.width - this.options.padding.right + 5,
884 | 5
885 | );
886 | this.volumeCtx.fillText(
887 | '0',
888 | this.options.width - this.options.padding.right + 5,
889 | this.volumeHeight - 5
890 | );
891 | }
892 |
893 | // 获取可见的数据点索引范围
894 | getVisibleDataIndices() {
895 | const chartWidth = this.options.width - this.options.padding.left - this.options.padding.right;
896 | const totalWidth = this.getFixedWidthChartWidth();
897 |
898 | // 计算可见区域对应的数据点索引
899 | let firstVisibleIndex = 0;
900 | let lastVisibleIndex = this.data.length - 1;
901 |
902 | if (totalWidth > chartWidth) {
903 | // 计算当前滚动位置下第一个可见的点
904 | const barWidthWithSpacing = this.options.barWidth + this.options.barSpacing;
905 | firstVisibleIndex = Math.floor(this.scrollOffsetX / barWidthWithSpacing);
906 |
907 | // 计算可见区域内可显示的数据点数量
908 | const visiblePointsCount = Math.ceil(chartWidth / barWidthWithSpacing);
909 |
910 | // 计算最后一个可见点
911 | lastVisibleIndex = Math.min(this.data.length - 1, firstVisibleIndex + visiblePointsCount);
912 |
913 | // 确保至少有一个点是可见的
914 | firstVisibleIndex = Math.max(0, firstVisibleIndex);
915 | lastVisibleIndex = Math.max(firstVisibleIndex, lastVisibleIndex);
916 | }
917 |
918 | return { first: firstVisibleIndex, last: lastVisibleIndex };
919 | }
920 |
921 | // 获取数据点在屏幕上的X坐标
922 | getDataPointScreenX(index) {
923 | const chartWidth = this.options.width - this.options.padding.left - this.options.padding.right;
924 | const totalWidth = this.getFixedWidthChartWidth();
925 |
926 | // 获取数据点的原始X坐标
927 | const x = this.getFixedWidthXPosition(index);
928 |
929 | // 应用滚动偏移
930 | let screenX = x;
931 | if (totalWidth > chartWidth) {
932 | screenX = x - this.scrollOffsetX;
933 | }
934 |
935 | // 加上左侧内边距
936 | return screenX + this.options.padding.left;
937 | }
938 |
939 | // 计算固定间距情况下的单个数据点X坐标
940 | getFixedWidthXPosition(index) {
941 | const barWidth = this.options.barWidth;
942 | const barSpacing = this.options.barSpacing;
943 |
944 | // 每个数据点占据固定宽度
945 | const pointWidth = barWidth + barSpacing;
946 |
947 | // 从左侧开始计算位置
948 | return index * pointWidth;
949 | }
950 |
951 | // 计算固定间距图表的总宽度
952 | getFixedWidthChartWidth() {
953 | const barWidth = this.options.barWidth;
954 | const barSpacing = this.options.barSpacing;
955 | const pointWidth = barWidth + barSpacing;
956 |
957 | // 添加右侧留白,确保最新数据点不会贴着边界
958 | return (this.data.length * pointWidth) + this.options.rightOffset;
959 | }
960 |
961 | drawText(text, x, y, options = {}) {
962 | const ctx = options.ctx || this.ctx;
963 | ctx.font = `${options.fontSize || 12}px ${options.fontFamily || 'Arial'}`;
964 | ctx.fillStyle = options.color || this.options.colors.text;
965 | ctx.textAlign = options.textAlign || 'left';
966 | ctx.textBaseline = options.textBaseline || 'top';
967 | ctx.fillText(text, x, y);
968 | }
969 |
970 | // Utility methods
971 | mapPriceToY(price) {
972 | const chartHeight = this.options.height - this.options.padding.top - this.options.padding.bottom;
973 | const priceRange = this.yRange.max - this.yRange.min;
974 | const ratio = (price - this.yRange.min) / priceRange;
975 | return this.options.height - this.options.padding.bottom - (ratio * chartHeight);
976 | }
977 |
978 | mapTimeToX(time) {
979 | const chartWidth = this.options.width - this.options.padding.left - this.options.padding.right;
980 | const timeRange = this.xRange.max - this.xRange.min;
981 | const ratio = (time.getTime() - this.xRange.min) / timeRange;
982 | return this.options.padding.left + (ratio * chartWidth);
983 | }
984 |
985 | mapVolumeToHeight(volume) {
986 | const ratio = volume / this.volumeRange.max;
987 | return ratio * this.volumeHeight;
988 | }
989 |
990 | formatTime(date) {
991 | const hours = date.getHours().toString().padStart(2, '0');
992 | const minutes = date.getMinutes().toString().padStart(2, '0');
993 | const seconds = date.getSeconds().toString().padStart(2, '0');
994 | return `${hours}:${minutes}:${seconds}`;
995 | }
996 |
997 | formatNumber(num) {
998 | if (num >= 10000000) {
999 | return (num / 10000000).toFixed(2) + '千万';
1000 | } else if (num >= 10000) {
1001 | return (num / 10000).toFixed(2) + '万';
1002 | } else if (num >= 1000) {
1003 | return (num / 1000).toFixed(2) + 'k';
1004 | }
1005 | return num.toFixed(2);
1006 | }
1007 |
1008 | // Public API
1009 | updateOptions(newOptions) {
1010 | // 处理主题切换
1011 | if (newOptions.theme && newOptions.theme !== this.options.theme) {
1012 | this.switchTheme(newOptions.theme);
1013 | delete newOptions.theme; // 已处理主题切换,移除避免重复处理
1014 | }
1015 |
1016 | // 处理特殊选项
1017 | if (newOptions.enableScroll !== undefined &&
1018 | newOptions.enableScroll !== this.options.enableScroll) {
1019 | this.options.enableScroll = newOptions.enableScroll;
1020 |
1021 | // 根据滚动配置更新事件监听
1022 | if (this.options.enableScroll) {
1023 | this.setupScrollEvents();
1024 | } else {
1025 | // 禁用滚动,重置到最新数据
1026 | this.scrollOffsetX = 0;
1027 | this.options.scrollPosition = 1.0;
1028 | }
1029 | }
1030 |
1031 | // 处理信息面板显示设置
1032 | if (newOptions.infoBarEnabled !== undefined &&
1033 | newOptions.infoBarEnabled !== this.options.infoBarEnabled) {
1034 | this.options.infoBarEnabled = newOptions.infoBarEnabled;
1035 | // 更新信息面板显示状态
1036 | this.infoPanel.style.display = this.options.infoBarEnabled ? 'block' : 'none';
1037 | }
1038 |
1039 | // 处理初始价格更新
1040 | if (newOptions.initialPrice !== undefined &&
1041 | newOptions.initialPrice !== this.options.initialPrice) {
1042 | // 使用setInitialPrice方法更新初始价格,它会处理价格范围计算和重绘
1043 | this.setInitialPrice(newOptions.initialPrice);
1044 | delete newOptions.initialPrice; // 已处理,避免重复处理
1045 | }
1046 |
1047 | // 处理坐标类型变更
1048 | if (newOptions.coordinateType !== undefined &&
1049 | newOptions.coordinateType !== this.options.coordinateType) {
1050 | this.options.coordinateType = newOptions.coordinateType;
1051 |
1052 | // 重新计算价格范围
1053 | this.calculatePriceRange();
1054 | }
1055 |
1056 | // 处理涨跌停百分比变更
1057 | if (newOptions.limitPercentage !== undefined &&
1058 | newOptions.limitPercentage !== this.options.limitPercentage) {
1059 | this.options.limitPercentage = newOptions.limitPercentage;
1060 |
1061 | // 如果当前是涨停板坐标,需要重新计算价格范围
1062 | if (this.options.coordinateType === 'limit') {
1063 | this.calculatePriceRange();
1064 | }
1065 | }
1066 |
1067 | // 更新其他选项
1068 | Object.assign(this.options, newOptions);
1069 | this.render();
1070 | }
1071 |
1072 | // Mock data generation for testing
1073 | static generateMockData(numPoints = 100, startTime = new Date(), startPrice = 100) {
1074 | const data = [];
1075 | let price = startPrice;
1076 | let time = new Date(startTime);
1077 |
1078 | for (let i = 0; i < numPoints; i++) {
1079 | // Random price movement
1080 | const change = (Math.random() - 0.5) * 2;
1081 | price = Math.max(1, price + change);
1082 |
1083 | // Random volume
1084 | const volume = Math.floor(Math.random() * 10000) + 1000;
1085 |
1086 | data.push({
1087 | time: new Date(time),
1088 | price,
1089 | volume
1090 | });
1091 |
1092 | // Add seconds
1093 | time = new Date(time.getTime() + 1000);
1094 | }
1095 |
1096 | return data;
1097 | }
1098 |
1099 | // 添加鼠标事件监听
1100 | setupMouseEvents() {
1101 | this.chartContainer.addEventListener('mousemove', this.handleMouseMove.bind(this));
1102 | this.chartContainer.addEventListener('mouseenter', this.handleMouseEnter.bind(this));
1103 | this.chartContainer.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
1104 |
1105 | // 添加时间轴的点击事件处理
1106 | this.timeAxisCanvas.addEventListener('click', this.handleTimeAxisClick.bind(this));
1107 | }
1108 |
1109 | // 处理鼠标进入
1110 | handleMouseEnter() {
1111 | this.isMouseOver = true;
1112 | }
1113 |
1114 | // 处理鼠标离开
1115 | handleMouseLeave() {
1116 | this.isMouseOver = false;
1117 | this.hideTooltip();
1118 | this.render(); // 重绘清除十字线
1119 | }
1120 |
1121 | // 创建工具提示元素
1122 | createTooltip() {
1123 | this.tooltip = document.createElement('div');
1124 | this.tooltip.className = 'fenshi-tooltip';
1125 | this.tooltip.style.position = 'absolute';
1126 | this.tooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
1127 | this.tooltip.style.color = '#fff';
1128 | this.tooltip.style.padding = '8px 12px';
1129 | this.tooltip.style.borderRadius = '4px';
1130 | this.tooltip.style.fontSize = '12px';
1131 | this.tooltip.style.pointerEvents = 'none';
1132 | this.tooltip.style.zIndex = '10';
1133 | this.tooltip.style.display = 'none';
1134 | this.tooltip.style.whiteSpace = 'nowrap';
1135 | this.tooltip.style.boxShadow = '0 2px 5px rgba(0, 0, 0, 0.3)';
1136 | this.tooltip.style.transition = 'transform 0.1s ease-out';
1137 | this.tooltip.style.transformOrigin = 'left center';
1138 |
1139 | this.chartContainer.appendChild(this.tooltip);
1140 | }
1141 |
1142 | // 更新工具提示内容和位置
1143 | updateTooltip() {
1144 | if (this.hoveredDataIndex < 0 || this.hoveredDataIndex >= this.data.length) {
1145 | this.hideTooltip();
1146 | return;
1147 | }
1148 |
1149 | const dataPoint = this.data[this.hoveredDataIndex];
1150 | const avgPrice = this.averageData[this.hoveredDataIndex];
1151 |
1152 | if (!dataPoint) return;
1153 |
1154 | // 计算涨跌和涨跌幅
1155 | const openPrice = this.priceInfo.open;
1156 | const change = dataPoint.price - openPrice;
1157 | const changePercent = (change / openPrice) * 100;
1158 | const changeSign = change >= 0 ? '+' : '';
1159 |
1160 | // 计算总金额(价格 * 成交量)
1161 | const amount = dataPoint.price * dataPoint.volume;
1162 |
1163 | // 更新提示框内容,添加均价信息
1164 | this.tooltip.innerHTML = `
1165 | ${this.formatTime(dataPoint.time)}
1166 | 价格: ${dataPoint.price.toFixed(2)}
1167 | ${avgPrice !== undefined ? `均价: ${avgPrice.toFixed(2)}
` : ''}
1168 | 涨跌: ${changeSign}${change.toFixed(2)} (${changeSign}${changePercent.toFixed(2)}%)
1169 | 成交量: ${this.formatNumber(dataPoint.volume)}
1170 | 金额: ${this.formatNumber(amount)}
1171 | `;
1172 |
1173 | // 更新提示框位置 - 跟随鼠标
1174 | const rect = this.chartContainer.getBoundingClientRect();
1175 | const offsetX = 15; // 鼠标右侧偏移量
1176 | const offsetY = 10; // 鼠标上方偏移量
1177 |
1178 | let tooltipX = this.mousePosition.x + offsetX;
1179 | let tooltipY = this.mousePosition.y - offsetY;
1180 |
1181 | // 确保提示框不超出容器右侧
1182 | const tooltipRect = this.tooltip.getBoundingClientRect();
1183 | if (tooltipX + tooltipRect.width > rect.width) {
1184 | tooltipX = this.mousePosition.x - tooltipRect.width - offsetX;
1185 | }
1186 |
1187 | // 确保提示框不超出容器顶部
1188 | if (tooltipY - tooltipRect.height < 0) {
1189 | tooltipY = this.mousePosition.y + tooltipRect.height + offsetY;
1190 | }
1191 |
1192 | this.tooltip.style.left = `${tooltipX}px`;
1193 | this.tooltip.style.top = `${tooltipY}px`;
1194 | this.tooltip.style.display = 'block';
1195 | }
1196 |
1197 | // 隐藏工具提示
1198 | hideTooltip() {
1199 | if (this.tooltip) {
1200 | this.tooltip.style.display = 'none';
1201 | }
1202 | }
1203 |
1204 | // 更新当前悬停的数据点索引
1205 | updateHoveredDataIndex() {
1206 | if (!this.isMouseOver || this.data.length === 0) {
1207 | this.hoveredDataIndex = -1;
1208 | return;
1209 | }
1210 |
1211 | // 鼠标在价格图内
1212 | if (this.mousePosition.y >= 0 && this.mousePosition.y <= this.options.height) {
1213 | const chartWidth = this.options.width - this.options.padding.left - this.options.padding.right;
1214 |
1215 | // 确定可见数据范围
1216 | const visibleIndices = this.getVisibleDataIndices();
1217 | const firstVisibleIndex = visibleIndices.first;
1218 | const lastVisibleIndex = visibleIndices.last;
1219 |
1220 | // 如果鼠标在图表数据区域内
1221 | if (this.mousePosition.x >= this.options.padding.left &&
1222 | this.mousePosition.x <= this.options.width - this.options.padding.right) {
1223 |
1224 | // 获取鼠标相对于图表左边缘的位置
1225 | const mouseX = this.mousePosition.x - this.options.padding.left;
1226 |
1227 | // 计算每个数据点在屏幕上的实际宽度
1228 | const barWidthWithSpacing = this.options.barWidth + this.options.barSpacing;
1229 | const visiblePointsCount = lastVisibleIndex - firstVisibleIndex + 1;
1230 |
1231 | let hoveredIndex = -1;
1232 |
1233 | if (visiblePointsCount <= 1) {
1234 | // 如果只有一个可见数据点
1235 | hoveredIndex = firstVisibleIndex;
1236 | } else {
1237 | // 对于固定宽度模式,直接计算鼠标位置对应的数据点
1238 | const barPositions = [];
1239 |
1240 | // 计算每个可见数据点的屏幕X坐标
1241 | for (let i = firstVisibleIndex; i <= lastVisibleIndex; i++) {
1242 | const x = this.getDataPointScreenX(i) - this.options.padding.left;
1243 | barPositions.push({ index: i, x: x });
1244 | }
1245 |
1246 | // 找到最接近鼠标位置的数据点
1247 | let closestBar = null;
1248 | let minDistance = Number.MAX_VALUE;
1249 |
1250 | for (const bar of barPositions) {
1251 | const distance = Math.abs(bar.x - mouseX);
1252 | if (distance < minDistance) {
1253 | minDistance = distance;
1254 | closestBar = bar;
1255 | }
1256 | }
1257 |
1258 | if (closestBar) {
1259 | hoveredIndex = closestBar.index;
1260 | }
1261 | }
1262 |
1263 | this.hoveredDataIndex = hoveredIndex;
1264 | } else {
1265 | this.hoveredDataIndex = -1;
1266 | }
1267 | } else {
1268 | this.hoveredDataIndex = -1;
1269 | }
1270 | }
1271 |
1272 | // 绘制十字线
1273 | drawCrosshair() {
1274 | if (this.hoveredDataIndex < 0 || this.hoveredDataIndex >= this.data.length) return;
1275 |
1276 | const dataPoint = this.data[this.hoveredDataIndex];
1277 |
1278 | // 获取数据点对应的坐标
1279 | const x = this.getDataPointScreenX(this.hoveredDataIndex);
1280 | const y = this.mapPriceToY(dataPoint.price);
1281 |
1282 | // 绘制垂直线 - 通过价格图和成交量图
1283 | this.ctx.beginPath();
1284 | this.ctx.strokeStyle = this.options.colors.crosshair;
1285 | this.ctx.setLineDash([1.5, 1.5]);
1286 | this.ctx.lineWidth = 1;
1287 | this.ctx.moveTo(x, this.options.padding.top);
1288 | this.ctx.lineTo(x, this.options.height - this.options.padding.bottom);
1289 | this.ctx.stroke();
1290 |
1291 | this.volumeCtx.beginPath();
1292 | this.volumeCtx.strokeStyle = this.options.colors.crosshair;
1293 | this.volumeCtx.setLineDash([1.5, 1.5]);
1294 | this.volumeCtx.lineWidth = 1;
1295 | this.volumeCtx.moveTo(x, 0);
1296 | this.volumeCtx.lineTo(x, this.volumeHeight);
1297 | this.volumeCtx.stroke();
1298 |
1299 | // 也在时间轴上标记当前位置
1300 | this.timeAxisCtx.beginPath();
1301 | this.timeAxisCtx.strokeStyle = this.options.colors.crosshair;
1302 | this.timeAxisCtx.setLineDash([]);
1303 | this.timeAxisCtx.lineWidth = 1.5;
1304 | this.timeAxisCtx.moveTo(x, 0);
1305 | this.timeAxisCtx.lineTo(x, this.options.timeAxisHeight);
1306 | this.timeAxisCtx.stroke();
1307 |
1308 | // 绘制水平线 - 仅在价格图
1309 | this.ctx.beginPath();
1310 | this.ctx.moveTo(this.options.padding.left, y);
1311 | this.ctx.lineTo(this.options.width - this.options.padding.right, y);
1312 | this.ctx.stroke();
1313 |
1314 | // 重置线条样式
1315 | this.ctx.setLineDash([]);
1316 | this.volumeCtx.setLineDash([]);
1317 |
1318 | // 计算涨跌幅百分比
1319 | const percentChange = ((dataPoint.price - this.priceInfo.open) / this.priceInfo.open) * 100;
1320 | const isUp = percentChange >= 0;
1321 |
1322 | // 根据涨跌确定显示颜色
1323 | const textColor = isUp ? this.options.colors.upBar : this.options.colors.downBar;
1324 |
1325 | // 格式化显示文本
1326 | const priceChangeText = (isUp ? "+" : "") + percentChange.toFixed(2) + "%";
1327 |
1328 | // 绘制价格标签
1329 | this.drawCrosshairLabel(this.ctx, priceChangeText, this.options.width - this.options.padding.right, y, 'price', textColor);
1330 |
1331 | // 绘制时间标签(时间轴上)
1332 | const timeText = this.formatTime(dataPoint.time);
1333 | this.timeAxisCtx.fillStyle = this.options.colors.background;
1334 | this.timeAxisCtx.fillRect(x - 40, 0, 80, this.options.timeAxisHeight);
1335 | this.timeAxisCtx.fillStyle = this.options.colors.text;
1336 | this.timeAxisCtx.font = 'bold 10px Arial';
1337 | this.timeAxisCtx.textAlign = 'center';
1338 | this.timeAxisCtx.textBaseline = 'middle';
1339 | this.timeAxisCtx.fillText(timeText, x, this.options.timeAxisHeight / 2);
1340 |
1341 | // 突出显示当前悬停点
1342 | this.highlightDataPoint(x, y);
1343 | }
1344 |
1345 | // 绘制十字线标签(可复用的方法)
1346 | drawCrosshairLabel(ctx, text, x, y, type, textColor) {
1347 | const textWidth = ctx.measureText(text).width + 10;
1348 | const textHeight = 20;
1349 |
1350 | // 根据标签类型调整位置
1351 | let labelX = x;
1352 | let labelY = y;
1353 |
1354 | if (type === 'price') {
1355 | // 价格标签绘制在右侧
1356 | labelX = x;
1357 | labelY = y - textHeight / 2;
1358 | } else if (type === 'time') {
1359 | // 时间标签绘制在底部并水平居中
1360 | labelX = x - textWidth / 2;
1361 | labelY = y - textHeight / 2;
1362 | }
1363 |
1364 | // 绘制标签背景
1365 | ctx.fillStyle = this.options.colors.background;
1366 | ctx.strokeStyle = this.options.colors.crosshair;
1367 | ctx.fillRect(labelX, labelY, textWidth, textHeight);
1368 | ctx.strokeRect(labelX, labelY, textWidth, textHeight);
1369 |
1370 | // 绘制文本(使用传入的颜色)
1371 | ctx.fillStyle = textColor || this.options.colors.text;
1372 |
1373 | if (type === 'price') {
1374 | ctx.textAlign = 'left';
1375 | ctx.textBaseline = 'middle';
1376 | ctx.fillText(text, labelX + 5, y);
1377 | } else if (type === 'time') {
1378 | ctx.textAlign = 'center';
1379 | ctx.textBaseline = 'middle';
1380 | ctx.fillText(text, x, y);
1381 | }
1382 | }
1383 |
1384 | // 绘制当前悬停点
1385 | highlightDataPoint(x, y) {
1386 | // 绘制外圈
1387 | this.ctx.beginPath();
1388 | this.ctx.arc(x, y, 5, 0, Math.PI * 2);
1389 | this.ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
1390 | this.ctx.fill();
1391 |
1392 | // 绘制内圈
1393 | this.ctx.beginPath();
1394 | this.ctx.arc(x, y, 3, 0, Math.PI * 2);
1395 | this.ctx.fillStyle = 'white';
1396 | this.ctx.fill();
1397 | this.ctx.strokeStyle = this.options.colors.crosshair;
1398 | this.ctx.lineWidth = 1;
1399 | this.ctx.stroke();
1400 | }
1401 |
1402 | // 绘制独立的时间轴
1403 | drawTimeAxis() {
1404 | if (this.data.length === 0) return;
1405 |
1406 | this.timeAxisCtx.fillStyle = this.options.colors.background;
1407 | this.timeAxisCtx.fillRect(0, 0, this.options.width, this.options.timeAxisHeight);
1408 |
1409 | // 计算水平网格线
1410 | this.timeAxisCtx.beginPath();
1411 | this.timeAxisCtx.strokeStyle = this.options.colors.grid;
1412 | this.timeAxisCtx.lineWidth = 0.5;
1413 | this.timeAxisCtx.moveTo(this.options.padding.left, 0);
1414 | this.timeAxisCtx.lineTo(this.options.width - this.options.padding.right, 0);
1415 | this.timeAxisCtx.stroke();
1416 |
1417 | // 获取可见的数据点
1418 | const visibleIndices = this.getVisibleDataIndices();
1419 | const firstVisibleIndex = visibleIndices.first;
1420 | const lastVisibleIndex = visibleIndices.last;
1421 |
1422 | // 计算可见数据点数量
1423 | const visibleDataPoints = lastVisibleIndex - firstVisibleIndex + 1;
1424 |
1425 | // 根据可见柱子数量确定要显示的时间标签数量
1426 | let numLabels;
1427 |
1428 | if (visibleDataPoints <= 40) {
1429 | // 当可见柱子数量小于等于40,只显示最新的一个时间点
1430 | numLabels = 1;
1431 | // 只画最新的时间点
1432 | this.drawTimeLabel(lastVisibleIndex);
1433 |
1434 | // 绘制垂直网格线
1435 | const x = this.getDataPointScreenX(lastVisibleIndex);
1436 | this.timeAxisCtx.beginPath();
1437 | this.timeAxisCtx.strokeStyle = this.options.colors.grid;
1438 | this.timeAxisCtx.setLineDash([5, 5]);
1439 | this.timeAxisCtx.moveTo(x, 0);
1440 | this.timeAxisCtx.lineTo(x, 5);
1441 | this.timeAxisCtx.stroke();
1442 | this.timeAxisCtx.setLineDash([]);
1443 | return;
1444 | } else if (visibleDataPoints > 40 && visibleDataPoints <= 60) {
1445 | // 40-60个柱子显示3个时间标签
1446 | numLabels = 3;
1447 | } else if (visibleDataPoints > 60 && visibleDataPoints <= 90) {
1448 | // 60-90个柱子显示4个时间标签
1449 | numLabels = 4;
1450 | } else {
1451 | // 90个以上柱子显示5个时间标签
1452 | numLabels = 5;
1453 | }
1454 |
1455 | // 计算时间点索引,确保均匀分布
1456 | for (let i = 0; i < numLabels; i++) {
1457 | // 计算均匀分布的索引,确保第一个点是firstVisibleIndex,最后一个点是lastVisibleIndex
1458 | const ratio = i / (numLabels - 1);
1459 | const dataIndex = Math.round(firstVisibleIndex + ratio * (lastVisibleIndex - firstVisibleIndex));
1460 |
1461 | // 绘制时间标签
1462 | this.drawTimeLabel(dataIndex);
1463 |
1464 | // 绘制垂直网格线
1465 | const x = this.getDataPointScreenX(dataIndex);
1466 | this.timeAxisCtx.beginPath();
1467 | this.timeAxisCtx.strokeStyle = this.options.colors.grid;
1468 | this.timeAxisCtx.setLineDash([5, 5]);
1469 | this.timeAxisCtx.moveTo(x, 0);
1470 | this.timeAxisCtx.lineTo(x, 5);
1471 | this.timeAxisCtx.stroke();
1472 | this.timeAxisCtx.setLineDash([]);
1473 | }
1474 | }
1475 |
1476 | // 绘制单个时间标签
1477 | drawTimeLabel(dataIndex) {
1478 | if (dataIndex < 0 || dataIndex >= this.data.length) return;
1479 |
1480 | const time = this.data[dataIndex].time;
1481 | const x = this.getDataPointScreenX(dataIndex);
1482 |
1483 | // 绘制时间标签
1484 | this.timeAxisCtx.fillStyle = this.options.colors.text;
1485 | this.timeAxisCtx.font = '10px Arial';
1486 | this.timeAxisCtx.textAlign = 'center';
1487 | this.timeAxisCtx.textBaseline = 'top';
1488 |
1489 | // 根据时间格式化(可以根据需要添加日期)
1490 | const timeText = this.formatTime(time);
1491 |
1492 | // 检查当前时间是否跨天或是特殊时间点
1493 | const hours = time.getHours();
1494 | const minutes = time.getMinutes();
1495 |
1496 | // 对于特殊时间点(开盘、中午、收盘等),添加更详细的标签
1497 | let additionalText = '';
1498 | // if (hours === 9 && minutes === 30) {
1499 | // additionalText = '开盘';
1500 | // } else if (hours === 11 && minutes === 30) {
1501 | // additionalText = '午休';
1502 | // } else if (hours === 13 && minutes === 0) {
1503 | // additionalText = '开盘';
1504 | // } else if (hours === 15 && minutes === 0) {
1505 | // additionalText = '收盘';
1506 | // }
1507 |
1508 | if (additionalText) {
1509 | // 绘制两行标签
1510 | this.timeAxisCtx.fillText(timeText, x, 8);
1511 | this.timeAxisCtx.fillText(additionalText, x, 22);
1512 | } else {
1513 | // 只绘制时间
1514 | this.timeAxisCtx.fillText(timeText, x, 15);
1515 | }
1516 | }
1517 |
1518 | // 处理时间轴点击,可以用于跳转到特定时间点
1519 | handleTimeAxisClick(e) {
1520 | if (!this.options.enableScroll) return;
1521 |
1522 | const rect = this.timeAxisCanvas.getBoundingClientRect();
1523 | const x = e.clientX - rect.left;
1524 |
1525 | // 仅处理在有效区域内的点击
1526 | if (x >= this.options.padding.left && x <= this.options.width - this.options.padding.right) {
1527 | // 找到最接近点击位置的数据点
1528 | const visibleIndices = this.getVisibleDataIndices();
1529 |
1530 | // 获取屏幕坐标到数据索引的映射
1531 | let closestIndex = -1;
1532 | let minDistance = Number.MAX_VALUE;
1533 |
1534 | for (let i = visibleIndices.first; i <= visibleIndices.last; i++) {
1535 | const dataX = this.getDataPointScreenX(i);
1536 | const distance = Math.abs(dataX - x);
1537 |
1538 | if (distance < minDistance) {
1539 | minDistance = distance;
1540 | closestIndex = i;
1541 | }
1542 | }
1543 |
1544 | if (closestIndex >= 0) {
1545 | // 设置十字线位置到点击的时间点
1546 | this.hoveredDataIndex = closestIndex;
1547 | this.render();
1548 |
1549 | if (this.options.tooltipEnabled) {
1550 | this.updateTooltip();
1551 | }
1552 | }
1553 | }
1554 | }
1555 |
1556 | // 修改drawTimeLabels方法,与时间轴标签数量保持一致,只影响垂直网格线的绘制
1557 | drawTimeLabels() {
1558 | if (this.data.length === 0) return;
1559 |
1560 | // 获取可见的数据点
1561 | const visibleIndices = this.getVisibleDataIndices();
1562 | const firstVisibleIndex = visibleIndices.first;
1563 | const lastVisibleIndex = visibleIndices.last;
1564 |
1565 | // 计算可见数据点数量
1566 | const visibleDataPoints = lastVisibleIndex - firstVisibleIndex + 1;
1567 |
1568 | // 根据可见柱子数量确定要显示的垂直网格线数量
1569 | let numGridLines;
1570 |
1571 | if (visibleDataPoints <= 40) {
1572 | // 当可见柱子数量小于等于40,只显示最新的一个垂直线
1573 | numGridLines = 1;
1574 | // 只画最右侧的网格线
1575 | const x = this.getDataPointScreenX(lastVisibleIndex);
1576 |
1577 | // 在价格图上画垂直网格线
1578 | this.ctx.beginPath();
1579 | this.ctx.strokeStyle = this.options.colors.grid;
1580 | this.ctx.setLineDash([5, 5]);
1581 | this.ctx.moveTo(x, this.options.padding.top);
1582 | this.ctx.lineTo(x, this.options.height - this.options.padding.bottom);
1583 | this.ctx.stroke();
1584 |
1585 | // 在成交量图上画垂直网格线
1586 | this.volumeCtx.beginPath();
1587 | this.volumeCtx.strokeStyle = this.options.colors.grid;
1588 | this.volumeCtx.setLineDash([5, 5]);
1589 | this.volumeCtx.moveTo(x, 0);
1590 | this.volumeCtx.lineTo(x, this.volumeHeight);
1591 | this.volumeCtx.stroke();
1592 |
1593 | // 重置虚线样式,确保不会影响其他绘制
1594 | this.ctx.setLineDash([]);
1595 | this.volumeCtx.setLineDash([]);
1596 | return;
1597 | }
1598 | else if (visibleDataPoints > 40 && visibleDataPoints <= 60) {
1599 | // 40-60个柱子显示3个网格线
1600 | numGridLines = 3;
1601 | } else if (visibleDataPoints > 60 && visibleDataPoints <= 90) {
1602 | // 60-90个柱子显示4个网格线
1603 | numGridLines = 4;
1604 | } else {
1605 | // 90个以上柱子显示5个网格线
1606 | numGridLines = 5;
1607 | }
1608 |
1609 | // 绘制垂直网格线
1610 | for (let i = 0; i < numGridLines; i++) {
1611 | // 计算均匀分布的索引,确保第一个点是firstVisibleIndex,最后一个点是lastVisibleIndex
1612 | const ratio = i / (numGridLines - 1);
1613 | const dataIndex = Math.round(firstVisibleIndex + ratio * (lastVisibleIndex - firstVisibleIndex));
1614 | const x = this.getDataPointScreenX(dataIndex);
1615 |
1616 | // 在价格图上画垂直网格线
1617 | this.ctx.beginPath();
1618 | this.ctx.strokeStyle = this.options.colors.grid;
1619 | this.ctx.setLineDash([5, 5]);
1620 | this.ctx.moveTo(x, this.options.padding.top);
1621 | this.ctx.lineTo(x, this.options.height - this.options.padding.bottom);
1622 | this.ctx.stroke();
1623 |
1624 | // 在成交量图上画垂直网格线
1625 | this.volumeCtx.beginPath();
1626 | this.volumeCtx.strokeStyle = this.options.colors.grid;
1627 | this.volumeCtx.setLineDash([5, 5]);
1628 | this.volumeCtx.moveTo(x, 0);
1629 | this.volumeCtx.lineTo(x, this.volumeHeight);
1630 | this.volumeCtx.stroke();
1631 | }
1632 |
1633 | // 重置虚线样式
1634 | this.ctx.setLineDash([]);
1635 | this.volumeCtx.setLineDash([]);
1636 | }
1637 |
1638 | // 设置初始参考价格
1639 | setInitialPrice(price) {
1640 | if (typeof price === 'number' && price > 0) {
1641 | this.options.initialPrice = price;
1642 | this.priceInfo.open = price;
1643 |
1644 | // 如果是涨停板坐标,重新计算价格范围
1645 | if (this.options.coordinateType === 'limit') {
1646 | this.calculatePriceRange();
1647 | this.render();
1648 | }
1649 |
1650 | // 更新信息面板
1651 | this.updateInfoPanel();
1652 |
1653 | return true;
1654 | }
1655 | return false;
1656 | }
1657 | }
1658 |
1659 | // If using as ES module
1660 | if (typeof exports !== 'undefined') {
1661 | exports.FenshiChart = FenshiChart;
1662 | }
--------------------------------------------------------------------------------