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
392 lines
13 KiB
/**
|
|
* 功能:Canvas绘制方法。
|
|
* 作者:洪锡林
|
|
* 时间:2025年10月25日
|
|
*
|
|
* @format
|
|
*/
|
|
|
|
import { utils } from "./util.js";
|
|
export const HCharts = {
|
|
// 清除画布
|
|
clearCanvas(ctx, width, height) {
|
|
ctx.clearRect(0, 0, width, height);
|
|
ctx.setFillStyle("#ffffff");
|
|
ctx.fillRect(0, 0, width, height);
|
|
},
|
|
// 设置画布颜色
|
|
setCanvasColor(ctx, width, height, color) {
|
|
ctx.clearRect(0, 0, width, height);
|
|
ctx.setFillStyle(color);
|
|
ctx.fillRect(0, 0, width, height);
|
|
},
|
|
// 绘制文本工具函数
|
|
drawText(ctx, text, x, y, fontSize = 12, color = "#333", align = "left") {
|
|
ctx.setFontSize(fontSize);
|
|
ctx.setFillStyle(color);
|
|
ctx.setTextAlign(align);
|
|
ctx.fillText(text, x, y);
|
|
},
|
|
/**
|
|
* 功能:绘制网格系统。
|
|
* 作者:洪锡林
|
|
* 时间:2025年10月25日
|
|
*
|
|
* grid:[{
|
|
* top:顶部距离
|
|
* bottom:底部距离
|
|
* left:左侧距离
|
|
* right:右侧距离
|
|
* lineColor:网格线颜色
|
|
* lineWidth:网格线宽度
|
|
* horizontalLineNum:水平网格线数量
|
|
* verticalLineNum:垂直网格线数量
|
|
* label:{
|
|
* fontSize:字体大小
|
|
* color:字体颜色
|
|
* onlyTwo:是否只有两个标签
|
|
* text:[{
|
|
* value:值标签
|
|
* ratio:比例标签
|
|
* },{
|
|
* value:值标签
|
|
* ratio:比例标签
|
|
* },...]
|
|
* },...]
|
|
*/
|
|
// 绘制网格系统
|
|
drawGrid(ctx, width, height, grid, openTime, closeTime) {
|
|
// 测试数据
|
|
// const preClosePrice = prevClosePrice;
|
|
for (let i = 0; i < grid.length; ++i) {
|
|
const top = grid[i].top;
|
|
const bottom = grid[i].bottom;
|
|
const left = grid[i].left;
|
|
const right = grid[i].right;
|
|
const lineColor = grid[i].lineColor;
|
|
const lineWidth = grid[i].lineWidth;
|
|
const horizontalLineNum = grid[i].horizontalLineNum - 1;
|
|
const verticalLineNum = grid[i].verticalLineNum - 1;
|
|
let label;
|
|
if (grid[i].label) {
|
|
label = grid[i].label;
|
|
}
|
|
ctx.setStrokeStyle(lineColor);
|
|
ctx.setLineWidth(lineWidth);
|
|
|
|
// 画图底的开盘收盘时间
|
|
if (i == 0 && openTime && closeTime) {
|
|
HCharts.drawText(ctx, openTime, 6, height - bottom + 12, 14, "#686868", "left");
|
|
HCharts.drawText(ctx, closeTime, width - 6, height - bottom + 12, 14, "#686868", "right");
|
|
}
|
|
// 绘制水平网格线
|
|
for (let j = 0; j <= horizontalLineNum; j++) {
|
|
const y = top + (j * (height - bottom - top)) / horizontalLineNum;
|
|
ctx.beginPath();
|
|
if (label.lineStyle[j] == "dash") {
|
|
ctx.setLineDash([5, 5]);
|
|
}
|
|
ctx.moveTo(left, y);
|
|
ctx.lineTo(width - right, y);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
// 绘制垂直网格线
|
|
for (let i = 0; i <= verticalLineNum; i++) {
|
|
const x = ((width - left - right) * i) / verticalLineNum;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + left, top);
|
|
ctx.lineTo(x + left, height - bottom);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
},
|
|
// 绘制价格标签
|
|
drawAxisLabels(ctx, width, height, grid) {
|
|
for (let i = 0; i < grid.length; ++i) {
|
|
const top = grid[i].top;
|
|
const bottom = grid[i].bottom;
|
|
const left = grid[i].left;
|
|
const right = grid[i].right;
|
|
const horizontalLineNum = grid[i].horizontalLineNum - 1;
|
|
let label;
|
|
if (grid[i].label) {
|
|
label = grid[i].label;
|
|
}
|
|
// 绘制水平网格线
|
|
for (let j = 0; j <= horizontalLineNum; j++) {
|
|
const y = top + (j * (height - bottom - top)) / horizontalLineNum;
|
|
// 价格标签
|
|
if (label) {
|
|
let valueXText = left + 1;
|
|
let ratioXText = width - right - 1;
|
|
let yText = y + 10;
|
|
if (j == horizontalLineNum) {
|
|
yText = y - 1;
|
|
}
|
|
let valueAlign = "left";
|
|
let ratioAlign = "right";
|
|
let fontSize = label.fontSize;
|
|
let textColor = label.color[j];
|
|
if (label.onlyTwo) {
|
|
if (j == 0) {
|
|
HCharts.drawText(ctx, label.text[0].value, valueXText, yText, fontSize, label.color[0], valueAlign);
|
|
} else if (j == horizontalLineNum) {
|
|
HCharts.drawText(ctx, label.text[1].value, valueXText, yText, fontSize, label.color[1], valueAlign);
|
|
}
|
|
} else {
|
|
HCharts.drawText(ctx, label.text[j].value, valueXText, yText, fontSize, textColor, valueAlign);
|
|
}
|
|
|
|
if (typeof label.text[j]?.ratio !== "undefined") {
|
|
HCharts.drawText(ctx, label.text[j].ratio, ratioXText, yText, fontSize, textColor, ratioAlign);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
// 绘制价格曲线
|
|
drawPriceLine(ctx, width, height, data, grid, priceRange) {
|
|
if (!data.length) return;
|
|
// 上下边距1
|
|
const top = grid[0].top;
|
|
const bottom = grid[0].bottom;
|
|
const left = grid[0].left;
|
|
const right = grid[0].right;
|
|
const pointLen = 240;
|
|
const priceDiff = priceRange.max - priceRange.min;
|
|
// 绘制价格曲线
|
|
ctx.setStrokeStyle("#000");
|
|
ctx.setLineWidth(1);
|
|
ctx.beginPath();
|
|
|
|
data.forEach((item, index) => {
|
|
const x = left + (index * (width - left - right)) / pointLen;
|
|
const y = top + (height - top - bottom) * (1 - (item.price - priceRange.min) / priceDiff);
|
|
if (index === 0) {
|
|
ctx.moveTo(x, y);
|
|
} else {
|
|
// 使用贝塞尔曲线平滑连接
|
|
const prevPoint = data[index - 1];
|
|
const prevX = left + ((index - 1) * (width - left - right)) / pointLen;
|
|
const prevY = top + (height - top + -bottom) * (1 - (prevPoint.price - priceRange.min) / priceDiff);
|
|
const cp1x = (prevX + x) / 2;
|
|
const cp1y = prevY;
|
|
const cp2x = (prevX + x) / 2;
|
|
const cp2y = y;
|
|
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
|
|
}
|
|
});
|
|
ctx.stroke();
|
|
// 绘制渐变背景
|
|
HCharts.drawGradientBackground(ctx, width, height, data, grid, priceRange);
|
|
},
|
|
|
|
// 绘制渐变背景
|
|
drawGradientBackground(ctx, width, height, data, grid, priceRange) {
|
|
// 上下边距1
|
|
const top = grid[0].top;
|
|
const bottom = grid[0].bottom;
|
|
const left = grid[0].left;
|
|
const right = grid[0].right;
|
|
const pointLen = 240;
|
|
const priceDiff = priceRange.max - priceRange.min;
|
|
|
|
const gradient = ctx.createLinearGradient(0, left, 0, height - top);
|
|
gradient.addColorStop(0, "rgba(0, 0, 0, 0.3)");
|
|
gradient.addColorStop(1, "rgba(0, 0, 0, 0.05)");
|
|
|
|
ctx.beginPath();
|
|
|
|
// 绘制价格曲线路径
|
|
data.forEach((item, index) => {
|
|
const x = left + (index * (width - left - right)) / pointLen;
|
|
const y = top + (height - top - bottom) * (1 - (item.price - priceRange.min) / priceDiff);
|
|
|
|
if (index === 0) {
|
|
ctx.moveTo(x, y);
|
|
} else {
|
|
const prevPoint = data[index - 1];
|
|
const prevX = left + ((index - 1) * (width - left - right)) / pointLen;
|
|
const prevY = top + (height - top - bottom) * (1 - (prevPoint.price - priceRange.min) / priceDiff);
|
|
|
|
const cp1x = (prevX + x) / 2;
|
|
const cp1y = prevY;
|
|
const cp2x = (prevX + x) / 2;
|
|
const cp2y = y;
|
|
|
|
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
|
|
}
|
|
});
|
|
|
|
// 闭合路径
|
|
const lastX = left + ((data.length - 1) * (width - left - right)) / pointLen;
|
|
ctx.lineTo(lastX, height - bottom);
|
|
ctx.lineTo(left, height - bottom);
|
|
ctx.closePath();
|
|
|
|
ctx.setFillStyle(gradient);
|
|
ctx.fill();
|
|
},
|
|
// 绘制成交量
|
|
drawVolume(ctx, width, height, data, index, pointLen, grid, volumeRange, offset) {
|
|
if (!data.length) return;
|
|
|
|
const top = grid[index - 1].top;
|
|
const bottom = grid[index - 1].bottom;
|
|
const left = grid[index - 1].left;
|
|
const right = grid[index - 1].right;
|
|
|
|
data.forEach((item, index) => {
|
|
const x = offset + left + (index * (width - left - right)) / pointLen;
|
|
const barWidth = (width - left - right) / pointLen - 0.5;
|
|
const barHeight = (item.volume / volumeRange.max) * (height - bottom - top);
|
|
// 根据涨跌设置颜色
|
|
const isRise = index === 0 || item.price >= data[index - 1].price || item.close >= data[index - 1].close;
|
|
ctx.setFillStyle(isRise ? "green" : "red");
|
|
|
|
ctx.fillRect(x - barWidth / 2, height - bottom - barHeight, barWidth, barHeight);
|
|
});
|
|
},
|
|
// 字符宽度近似计算(避免使用 measureText)
|
|
getApproximateTextWidth(text, fontSize = 10) {
|
|
// 中文字符约等于 fontSize,英文字符约等于 fontSize * 0.6
|
|
let width = 0;
|
|
for (let char of text) {
|
|
// 判断是否为中文字符
|
|
if (char.match(/[\u4e00-\u9fa5]/)) {
|
|
width += fontSize;
|
|
} else {
|
|
width += fontSize * 0.6;
|
|
}
|
|
}
|
|
return width;
|
|
},
|
|
// 绘制顶部价格显示
|
|
drawTopPriceDisplay(ctx, grid, text) {
|
|
for (let i = 0; i < text.length; i++) {
|
|
let x = grid[i].left;
|
|
let y = grid[i].top - 4;
|
|
for (let j = 0; j < text[i].length; j++) {
|
|
ctx.setFillStyle(text[i][j].color);
|
|
ctx.setFontSize(10);
|
|
ctx.setTextAlign("left");
|
|
ctx.fillText(text[i][j].name + ":" + text[i][j].value, x, y);
|
|
x += HCharts.getApproximateTextWidth(text[i][j].name + ":" + text[i][j].value) + 5;
|
|
}
|
|
}
|
|
},
|
|
// 绘制坐标轴标签
|
|
drawCrosshairAxisLabels(ctx, width, height, grid, crosshair) {
|
|
const { x, y } = crosshair;
|
|
// X轴时间标签
|
|
if (crosshair.currentData && (crosshair.currentData.time || crosshair.currentData.date)) {
|
|
const timeText = crosshair.currentData.time || crosshair.currentData.date;
|
|
const xBoxWidth = crosshair.currentData.time ? 40 : 70;
|
|
const xBoxHeight = 15;
|
|
ctx.setFillStyle("#629AF5");
|
|
|
|
if (x - xBoxWidth / 2 <= grid[0].left) {
|
|
ctx.fillRect(grid[0].left, height - grid[0].bottom, xBoxWidth, xBoxHeight);
|
|
} else if (x + xBoxWidth / 2 < width - grid[0].right) {
|
|
ctx.fillRect(x - xBoxWidth / 2, height - grid[0].bottom, xBoxWidth, xBoxHeight);
|
|
} else {
|
|
ctx.fillRect(width - grid[0].right - xBoxWidth, height - grid[0].bottom, xBoxWidth, xBoxHeight);
|
|
}
|
|
|
|
ctx.setFillStyle("#fff");
|
|
ctx.setFontSize(12);
|
|
ctx.setTextAlign("center");
|
|
|
|
if (x - xBoxWidth / 2 <= grid[0].left) {
|
|
ctx.fillText(timeText, grid[0].left + xBoxWidth / 2, height - grid[0].bottom + 12);
|
|
} else if (x + xBoxWidth / 2 < width - grid[0].right) {
|
|
ctx.fillText(timeText, x, height - grid[0].bottom + 12);
|
|
} else {
|
|
ctx.fillText(timeText, width - grid[0].right - xBoxWidth / 2, height - grid[0].bottom + 12);
|
|
}
|
|
}
|
|
|
|
// Y轴价格标签
|
|
if (crosshair.currentData) {
|
|
const priceText = utils.formatPrice(crosshair.currentData.price);
|
|
const yBoxWidth = 50;
|
|
const yBoxHeight = 14;
|
|
ctx.setFillStyle("#629AF5");
|
|
if (x < grid[0].left + yBoxWidth + 5) {
|
|
ctx.fillRect(width - grid[0].right - yBoxWidth, y - yBoxHeight / 2, yBoxWidth, yBoxHeight);
|
|
} else {
|
|
ctx.fillRect(grid[0].left, y - yBoxHeight / 2, yBoxWidth, yBoxHeight);
|
|
}
|
|
|
|
ctx.setFillStyle("#fff");
|
|
ctx.setFontSize(11);
|
|
ctx.setTextAlign("center");
|
|
if (x < grid[0].left + yBoxWidth + 5) {
|
|
ctx.fillText(priceText, width - grid[0].right - yBoxWidth / 2, y + 3);
|
|
} else {
|
|
ctx.fillText(priceText, grid[0].left + yBoxWidth / 2, y + 3);
|
|
}
|
|
}
|
|
},
|
|
// 绘制十字准线
|
|
drawCrosshair(ctx, width, height, grid, crosshair, text) {
|
|
if (!ctx) return;
|
|
const { x, y } = crosshair;
|
|
if (crosshair.show) {
|
|
// 每次绘制前先清除整个画布
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
// 绘制垂直准线1
|
|
ctx.setStrokeStyle("#000");
|
|
ctx.setLineWidth(1);
|
|
// ctx.setLineDash([5, 5]);
|
|
|
|
for (let i = 0; i < grid.length; i++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, grid[i].top);
|
|
ctx.lineTo(x, height - grid[i].bottom);
|
|
ctx.stroke();
|
|
}
|
|
// ctx.beginPath();
|
|
// ctx.moveTo(x, grid[0].top);
|
|
// ctx.lineTo(x, height - grid[0].bottom);
|
|
// ctx.stroke();
|
|
|
|
// // 绘制垂直准线2
|
|
// ctx.beginPath();
|
|
// ctx.moveTo(x, grid[1].top);
|
|
// ctx.lineTo(x, height - grid[1].bottom);
|
|
// ctx.stroke();
|
|
|
|
// 绘制水平准线
|
|
ctx.beginPath();
|
|
ctx.moveTo(grid[0].left, y);
|
|
ctx.lineTo(width - grid[0].right, y);
|
|
ctx.stroke();
|
|
|
|
ctx.setLineDash([]);
|
|
|
|
// 绘制焦点圆点 - 黑边白心(更小)
|
|
// 先绘制白色填充
|
|
ctx.setFillStyle("#ffffff");
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// 再绘制黑色边框
|
|
ctx.setStrokeStyle("#000000");
|
|
ctx.setLineWidth(1);
|
|
ctx.setLineDash([]);
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 2, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// 绘制坐标轴标签
|
|
HCharts.drawCrosshairAxisLabels(ctx, width, height, grid, crosshair);
|
|
}
|
|
// 绘制顶部价格显示
|
|
HCharts.drawTopPriceDisplay(ctx, grid, text);
|
|
ctx.draw(false);
|
|
},
|
|
};
|