You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

392 lines
13 KiB

  1. /**
  2. * 功能Canvas绘制方法
  3. * 作者洪锡林
  4. * 时间2025年10月25日
  5. *
  6. * @format
  7. */
  8. import { utils } from "./util.js";
  9. export const HCharts = {
  10. // 清除画布
  11. clearCanvas(ctx, width, height) {
  12. ctx.clearRect(0, 0, width, height);
  13. ctx.setFillStyle("#ffffff");
  14. ctx.fillRect(0, 0, width, height);
  15. },
  16. // 设置画布颜色
  17. setCanvasColor(ctx, width, height, color) {
  18. ctx.clearRect(0, 0, width, height);
  19. ctx.setFillStyle(color);
  20. ctx.fillRect(0, 0, width, height);
  21. },
  22. // 绘制文本工具函数
  23. drawText(ctx, text, x, y, fontSize = 12, color = "#333", align = "left") {
  24. ctx.setFontSize(fontSize);
  25. ctx.setFillStyle(color);
  26. ctx.setTextAlign(align);
  27. ctx.fillText(text, x, y);
  28. },
  29. /**
  30. * 功能绘制网格系统
  31. * 作者洪锡林
  32. * 时间2025年10月25日
  33. *
  34. * grid:[{
  35. * top:顶部距离
  36. * bottom:底部距离
  37. * left:左侧距离
  38. * right:右侧距离
  39. * lineColor:网格线颜色
  40. * lineWidth:网格线宽度
  41. * horizontalLineNum:水平网格线数量
  42. * verticalLineNum:垂直网格线数量
  43. * label:{
  44. * fontSize:字体大小
  45. * color:字体颜色
  46. * onlyTwo:是否只有两个标签
  47. * text:[{
  48. * value:值标签
  49. * ratio:比例标签
  50. * },{
  51. * value:值标签
  52. * ratio:比例标签
  53. * },...]
  54. * },...]
  55. */
  56. // 绘制网格系统
  57. drawGrid(ctx, width, height, grid, openTime, closeTime) {
  58. // 测试数据
  59. // const preClosePrice = prevClosePrice;
  60. for (let i = 0; i < grid.length; ++i) {
  61. const top = grid[i].top;
  62. const bottom = grid[i].bottom;
  63. const left = grid[i].left;
  64. const right = grid[i].right;
  65. const lineColor = grid[i].lineColor;
  66. const lineWidth = grid[i].lineWidth;
  67. const horizontalLineNum = grid[i].horizontalLineNum - 1;
  68. const verticalLineNum = grid[i].verticalLineNum - 1;
  69. let label;
  70. if (grid[i].label) {
  71. label = grid[i].label;
  72. }
  73. ctx.setStrokeStyle(lineColor);
  74. ctx.setLineWidth(lineWidth);
  75. // 画图底的开盘收盘时间
  76. if (i == 0 && openTime && closeTime) {
  77. HCharts.drawText(ctx, openTime, 6, height - bottom + 12, 14, "#686868", "left");
  78. HCharts.drawText(ctx, closeTime, width - 6, height - bottom + 12, 14, "#686868", "right");
  79. }
  80. // 绘制水平网格线
  81. for (let j = 0; j <= horizontalLineNum; j++) {
  82. const y = top + (j * (height - bottom - top)) / horizontalLineNum;
  83. ctx.beginPath();
  84. if (label.lineStyle[j] == "dash") {
  85. ctx.setLineDash([5, 5]);
  86. }
  87. ctx.moveTo(left, y);
  88. ctx.lineTo(width - right, y);
  89. ctx.stroke();
  90. ctx.setLineDash([]);
  91. }
  92. // 绘制垂直网格线
  93. for (let i = 0; i <= verticalLineNum; i++) {
  94. const x = ((width - left - right) * i) / verticalLineNum;
  95. ctx.beginPath();
  96. ctx.moveTo(x + left, top);
  97. ctx.lineTo(x + left, height - bottom);
  98. ctx.stroke();
  99. }
  100. }
  101. },
  102. // 绘制价格标签
  103. drawAxisLabels(ctx, width, height, grid) {
  104. for (let i = 0; i < grid.length; ++i) {
  105. const top = grid[i].top;
  106. const bottom = grid[i].bottom;
  107. const left = grid[i].left;
  108. const right = grid[i].right;
  109. const horizontalLineNum = grid[i].horizontalLineNum - 1;
  110. let label;
  111. if (grid[i].label) {
  112. label = grid[i].label;
  113. }
  114. // 绘制水平网格线
  115. for (let j = 0; j <= horizontalLineNum; j++) {
  116. const y = top + (j * (height - bottom - top)) / horizontalLineNum;
  117. // 价格标签
  118. if (label) {
  119. let valueXText = left + 1;
  120. let ratioXText = width - right - 1;
  121. let yText = y + 10;
  122. if (j == horizontalLineNum) {
  123. yText = y - 1;
  124. }
  125. let valueAlign = "left";
  126. let ratioAlign = "right";
  127. let fontSize = label.fontSize;
  128. let textColor = label.color[j];
  129. if (label.onlyTwo) {
  130. if (j == 0) {
  131. HCharts.drawText(ctx, label.text[0].value, valueXText, yText, fontSize, label.color[0], valueAlign);
  132. } else if (j == horizontalLineNum) {
  133. HCharts.drawText(ctx, label.text[1].value, valueXText, yText, fontSize, label.color[1], valueAlign);
  134. }
  135. } else {
  136. HCharts.drawText(ctx, label.text[j].value, valueXText, yText, fontSize, textColor, valueAlign);
  137. }
  138. if (typeof label.text[j]?.ratio !== "undefined") {
  139. HCharts.drawText(ctx, label.text[j].ratio, ratioXText, yText, fontSize, textColor, ratioAlign);
  140. }
  141. }
  142. }
  143. }
  144. },
  145. // 绘制价格曲线
  146. drawPriceLine(ctx, width, height, data, grid, priceRange) {
  147. if (!data.length) return;
  148. // 上下边距1
  149. const top = grid[0].top;
  150. const bottom = grid[0].bottom;
  151. const left = grid[0].left;
  152. const right = grid[0].right;
  153. const pointLen = 240;
  154. const priceDiff = priceRange.max - priceRange.min;
  155. // 绘制价格曲线
  156. ctx.setStrokeStyle("#000");
  157. ctx.setLineWidth(1);
  158. ctx.beginPath();
  159. data.forEach((item, index) => {
  160. const x = left + (index * (width - left - right)) / pointLen;
  161. const y = top + (height - top - bottom) * (1 - (item.price - priceRange.min) / priceDiff);
  162. if (index === 0) {
  163. ctx.moveTo(x, y);
  164. } else {
  165. // 使用贝塞尔曲线平滑连接
  166. const prevPoint = data[index - 1];
  167. const prevX = left + ((index - 1) * (width - left - right)) / pointLen;
  168. const prevY = top + (height - top + -bottom) * (1 - (prevPoint.price - priceRange.min) / priceDiff);
  169. const cp1x = (prevX + x) / 2;
  170. const cp1y = prevY;
  171. const cp2x = (prevX + x) / 2;
  172. const cp2y = y;
  173. ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
  174. }
  175. });
  176. ctx.stroke();
  177. // 绘制渐变背景
  178. HCharts.drawGradientBackground(ctx, width, height, data, grid, priceRange);
  179. },
  180. // 绘制渐变背景
  181. drawGradientBackground(ctx, width, height, data, grid, priceRange) {
  182. // 上下边距1
  183. const top = grid[0].top;
  184. const bottom = grid[0].bottom;
  185. const left = grid[0].left;
  186. const right = grid[0].right;
  187. const pointLen = 240;
  188. const priceDiff = priceRange.max - priceRange.min;
  189. const gradient = ctx.createLinearGradient(0, left, 0, height - top);
  190. gradient.addColorStop(0, "rgba(0, 0, 0, 0.3)");
  191. gradient.addColorStop(1, "rgba(0, 0, 0, 0.05)");
  192. ctx.beginPath();
  193. // 绘制价格曲线路径
  194. data.forEach((item, index) => {
  195. const x = left + (index * (width - left - right)) / pointLen;
  196. const y = top + (height - top - bottom) * (1 - (item.price - priceRange.min) / priceDiff);
  197. if (index === 0) {
  198. ctx.moveTo(x, y);
  199. } else {
  200. const prevPoint = data[index - 1];
  201. const prevX = left + ((index - 1) * (width - left - right)) / pointLen;
  202. const prevY = top + (height - top - bottom) * (1 - (prevPoint.price - priceRange.min) / priceDiff);
  203. const cp1x = (prevX + x) / 2;
  204. const cp1y = prevY;
  205. const cp2x = (prevX + x) / 2;
  206. const cp2y = y;
  207. ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
  208. }
  209. });
  210. // 闭合路径
  211. const lastX = left + ((data.length - 1) * (width - left - right)) / pointLen;
  212. ctx.lineTo(lastX, height - bottom);
  213. ctx.lineTo(left, height - bottom);
  214. ctx.closePath();
  215. ctx.setFillStyle(gradient);
  216. ctx.fill();
  217. },
  218. // 绘制成交量
  219. drawVolume(ctx, width, height, data, index, pointLen, grid, volumeRange, offset) {
  220. if (!data.length) return;
  221. const top = grid[index - 1].top;
  222. const bottom = grid[index - 1].bottom;
  223. const left = grid[index - 1].left;
  224. const right = grid[index - 1].right;
  225. data.forEach((item, index) => {
  226. const x = offset + left + (index * (width - left - right)) / pointLen;
  227. const barWidth = (width - left - right) / pointLen - 0.5;
  228. const barHeight = (item.volume / volumeRange.max) * (height - bottom - top);
  229. // 根据涨跌设置颜色
  230. const isRise = index === 0 || item.price >= data[index - 1].price || item.close >= data[index - 1].close;
  231. ctx.setFillStyle(isRise ? "green" : "red");
  232. ctx.fillRect(x - barWidth / 2, height - bottom - barHeight, barWidth, barHeight);
  233. });
  234. },
  235. // 字符宽度近似计算(避免使用 measureText)
  236. getApproximateTextWidth(text, fontSize = 10) {
  237. // 中文字符约等于 fontSize,英文字符约等于 fontSize * 0.6
  238. let width = 0;
  239. for (let char of text) {
  240. // 判断是否为中文字符
  241. if (char.match(/[\u4e00-\u9fa5]/)) {
  242. width += fontSize;
  243. } else {
  244. width += fontSize * 0.6;
  245. }
  246. }
  247. return width;
  248. },
  249. // 绘制顶部价格显示
  250. drawTopPriceDisplay(ctx, grid, text) {
  251. for (let i = 0; i < text.length; i++) {
  252. let x = grid[i].left;
  253. let y = grid[i].top - 4;
  254. for (let j = 0; j < text[i].length; j++) {
  255. ctx.setFillStyle(text[i][j].color);
  256. ctx.setFontSize(10);
  257. ctx.setTextAlign("left");
  258. ctx.fillText(text[i][j].name + ":" + text[i][j].value, x, y);
  259. x += HCharts.getApproximateTextWidth(text[i][j].name + ":" + text[i][j].value) + 5;
  260. }
  261. }
  262. },
  263. // 绘制坐标轴标签
  264. drawCrosshairAxisLabels(ctx, width, height, grid, crosshair) {
  265. const { x, y } = crosshair;
  266. // X轴时间标签
  267. if (crosshair.currentData && (crosshair.currentData.time || crosshair.currentData.date)) {
  268. const timeText = crosshair.currentData.time || crosshair.currentData.date;
  269. const xBoxWidth = crosshair.currentData.time ? 40 : 70;
  270. const xBoxHeight = 15;
  271. ctx.setFillStyle("#629AF5");
  272. if (x - xBoxWidth / 2 <= grid[0].left) {
  273. ctx.fillRect(grid[0].left, height - grid[0].bottom, xBoxWidth, xBoxHeight);
  274. } else if (x + xBoxWidth / 2 < width - grid[0].right) {
  275. ctx.fillRect(x - xBoxWidth / 2, height - grid[0].bottom, xBoxWidth, xBoxHeight);
  276. } else {
  277. ctx.fillRect(width - grid[0].right - xBoxWidth, height - grid[0].bottom, xBoxWidth, xBoxHeight);
  278. }
  279. ctx.setFillStyle("#fff");
  280. ctx.setFontSize(12);
  281. ctx.setTextAlign("center");
  282. if (x - xBoxWidth / 2 <= grid[0].left) {
  283. ctx.fillText(timeText, grid[0].left + xBoxWidth / 2, height - grid[0].bottom + 12);
  284. } else if (x + xBoxWidth / 2 < width - grid[0].right) {
  285. ctx.fillText(timeText, x, height - grid[0].bottom + 12);
  286. } else {
  287. ctx.fillText(timeText, width - grid[0].right - xBoxWidth / 2, height - grid[0].bottom + 12);
  288. }
  289. }
  290. // Y轴价格标签
  291. if (crosshair.currentData) {
  292. const priceText = utils.formatPrice(crosshair.currentData.price);
  293. const yBoxWidth = 50;
  294. const yBoxHeight = 14;
  295. ctx.setFillStyle("#629AF5");
  296. if (x < grid[0].left + yBoxWidth + 5) {
  297. ctx.fillRect(width - grid[0].right - yBoxWidth, y - yBoxHeight / 2, yBoxWidth, yBoxHeight);
  298. } else {
  299. ctx.fillRect(grid[0].left, y - yBoxHeight / 2, yBoxWidth, yBoxHeight);
  300. }
  301. ctx.setFillStyle("#fff");
  302. ctx.setFontSize(11);
  303. ctx.setTextAlign("center");
  304. if (x < grid[0].left + yBoxWidth + 5) {
  305. ctx.fillText(priceText, width - grid[0].right - yBoxWidth / 2, y + 3);
  306. } else {
  307. ctx.fillText(priceText, grid[0].left + yBoxWidth / 2, y + 3);
  308. }
  309. }
  310. },
  311. // 绘制十字准线
  312. drawCrosshair(ctx, width, height, grid, crosshair, text) {
  313. if (!ctx) return;
  314. const { x, y } = crosshair;
  315. if (crosshair.show) {
  316. // 每次绘制前先清除整个画布
  317. ctx.clearRect(0, 0, width, height);
  318. // 绘制垂直准线1
  319. ctx.setStrokeStyle("#000");
  320. ctx.setLineWidth(1);
  321. // ctx.setLineDash([5, 5]);
  322. for (let i = 0; i < grid.length; i++) {
  323. ctx.beginPath();
  324. ctx.moveTo(x, grid[i].top);
  325. ctx.lineTo(x, height - grid[i].bottom);
  326. ctx.stroke();
  327. }
  328. // ctx.beginPath();
  329. // ctx.moveTo(x, grid[0].top);
  330. // ctx.lineTo(x, height - grid[0].bottom);
  331. // ctx.stroke();
  332. // // 绘制垂直准线2
  333. // ctx.beginPath();
  334. // ctx.moveTo(x, grid[1].top);
  335. // ctx.lineTo(x, height - grid[1].bottom);
  336. // ctx.stroke();
  337. // 绘制水平准线
  338. ctx.beginPath();
  339. ctx.moveTo(grid[0].left, y);
  340. ctx.lineTo(width - grid[0].right, y);
  341. ctx.stroke();
  342. ctx.setLineDash([]);
  343. // 绘制焦点圆点 - 黑边白心(更小)
  344. // 先绘制白色填充
  345. ctx.setFillStyle("#ffffff");
  346. ctx.beginPath();
  347. ctx.arc(x, y, 2, 0, Math.PI * 2);
  348. ctx.fill();
  349. // 再绘制黑色边框
  350. ctx.setStrokeStyle("#000000");
  351. ctx.setLineWidth(1);
  352. ctx.setLineDash([]);
  353. ctx.beginPath();
  354. ctx.arc(x, y, 2, 0, Math.PI * 2);
  355. ctx.stroke();
  356. // 绘制坐标轴标签
  357. HCharts.drawCrosshairAxisLabels(ctx, width, height, grid, crosshair);
  358. }
  359. // 绘制顶部价格显示
  360. HCharts.drawTopPriceDisplay(ctx, grid, text);
  361. ctx.draw(false);
  362. },
  363. };