需求:
1. 自适应父Dom的宽高,但设置canvas元素的最小宽高,小于最小宽高则设置父Dom带滚动条。
2. 窗口大小变化时,重新绘制折线图,以适应新的大小,保证折线图一直以最好效果展现;
3. x轴的坐标尺度为时间,单位是月份,数据类型是数组,数组元素是字符串,格式类似"2016/12"这种,为了方便,假设x轴固定有12个刻度,初始刻度为x=0的位置;
4. y轴尺度为数字,要求跟随数据的最大值而自动变化,以保证良好的可见性,并且需要绘制参考线;
5. 简单来说,参考excel表格自动生成的折线图而绘制。
分析需求:
1. canvas元素要自适配父Dom,因此要设法获得父Dom的大小,然后调整自身的大小;
2. canvas元素的宽高的设置,需要写在html元素中,而不能通过css设置(否则可能出问题),因此应该通过js显式的写出来(例如通过canvas.width这样的方法);
3. 折线图,首先确定xy坐标轴绘制在画布上的范围(比canvas画布小);
4. 然后绘制xy坐标轴;
5. 再确定表示数据的折线的范围,注意,数据折线的范围应该比xy坐标轴的范围要小,且左下角和xy坐标轴的原点重合,如此方能有更好的体验(否则若比如数据折线的最右边和坐标轴的最右边重合,是会容易带来误解的);
6. x轴的时间刻度为12个刻度,且初始刻度为x=0的位置,因此每个刻度的宽度 = 数据折线的总宽度/11;
7. 假如x轴的时间刻度为可变数量,也不难,每个刻度的宽度 = 数据折线总宽度(可确认) / (x轴刻度总数量 - 1) 即可;
8. 比较麻烦的y轴的刻度确认。
9. 首先要确认y轴刻度的最大值,遍历所有数据,取其最大值dataMaxY。
10. 然后将dataMaxY向上取值获得用于计算刻度的最大值MaxY,逻辑是这样的:
假如第一个数字是8或者9,,那么取比开头数字大1的数字。例如2开头就是3,6开头就是7,然后后面用0补足位数(以确保和原来的最大数值是同一个量级的)
假如第一个数字是1开头,例如13,那么取比其前两位大的偶数,例如13的话取14,14的话取16,18或19则取20;
11. 根据MaxY来确定y轴的刻度,存储刻度的为数组,数组的元素个数就是刻度的数量。
12. 当MaxY小于10时,固定刻度为2/格,最大10
13. 当MaxY为1开头时,刻度为2/格,最大刻度比MaxY要大;
14. 当MaxY为2~5开头时,刻度为5/格,最大刻度比MaxY大;
15. 当MaxY为6~9开头时,刻度为10/格,最大刻度比MaxY大;
16. 根据刻度数组,以及数据折线图的绘制区域,绘制参考线;
17. 至此y轴刻度和参考线完;
18. 根据刻度数组的最后一个元素(刻度的最大值),以及数据的值,外加数据折线图区域的坐标,可以绘制出折线图;
19. 绘制折线图时可以顺便写下x轴的刻度(x坐标和数据折线图当前数据坐标相同,y坐标固定);
20. 有必要的话,添加输入验证,如果输入错误,则在绘制区域显示错误文字。
21. 添加各种自定义设置,用于设置文字的样式、颜色,宽度大小等;
分拆需求为函数:
1. 获得并设置canvas标签的最小宽高,然后返回canvas中,绘图区域坐标(指x、y坐标轴的绘图坐标);
2. 绘制x、y坐标轴(不包含刻度);
3. 获得y轴最大尺度;
4. 确定y轴刻度数组;
5. 根据y轴刻度数组,绘制参考线和刻度的数字;
6. 绘制数据折线图和x坐标刻度;
7. 绘图函数,需要重新绘制图时,通过本方法来调用以上方法(本接口暴露出来可被外界调用);
8. 输入检查函数,用于提示错误;
9. 刷新函数,用于在窗口大小变化时主动调用绘图函数重新绘图。
10. 【测试用】自动生成数据的函数;
源代码如下:
<html>
<head>
<meta charset="UTF-8">
<title>用Canvas绘制折线图</title>
<style>
#test {
border: 1px solid black;
height: 100%;
width: 100%;
box-sizing: border-box;
}
</style>
</head>
<body>
<div id="test"></div>
<script>
var dom = document.getElementById("test"); //数据源,这里是模拟生成的
//数据生成函数
var caseInfo = (function () {
var caseInfo = [];
//最大值,实际使用中不要用这一条,这个只是用来生成数据的
var max = Math.pow(10, parseInt(Math.random() * 5));
for (let i = 0; i < 12; i++) {
caseInfo.push(parseInt(max * Math.random()));
}
console.log(caseInfo);
return caseInfo;
})();
// var caseInfo = [0, 0, 0, 14, 0, 0, 0, 0, 0, 0, 0, 0];
var dateText = ["2014/2", "2014/3", "2014/4", "2014/5", "2014/6", "2014/7", "2014/8", "2014/9", "2014/10", "2014/11", "2014/12", "2015/1"];
var draw = new drawCanvas(dom, caseInfo, dateText);
// 绘图函数的类,传入的参数依次为,canvas标签应该被放置的父Dom,数据,时间
// 1、父dom:支持自适应,最小宽高(此时会设置父dom的overflow为auto)
// 2、数据:标准的为12个数据,类型为number,不足12个会自动用0填充满(填充在数组的开始部分);
// 3、时间要求格式为:年份/月份,例如2016/12,类型为字符串,非该格式的会被识别为错误并报错(如需修改请自行更改相关判断部分);
// 4、y轴坐标的刻度会根据数据的最大值而自动变化;
// 5、x轴坐标的刻度自动设为12格
function drawCanvas(Dom, caseInfoArray, dateTextArray) {
// 设置
var color = {
xyAxisLine: "#000", //x、y坐标轴的颜色
xScale: "#000", //x轴刻度文字的颜色
yScale: "#000", //y轴刻度文字的颜色
referenceLine: "#bbb", //参考线带颜色
dataLine: "#f6793c", //数据线条的颜色
errorMessage: "#000" //错误提示的颜色
};
var font = {
yScale: "Microsoft YaHei 12px", //y轴刻度文字
xScale: "Microsoft YaHei 12px" //x轴刻度文字
};
var dataLineWidth = 3; //数据线条的宽度
var error = {
errorCaseInfo: "错误的数据",
errorCaseTpye: "数据类型不是数字,无法绘图",
errorDate: "错误的时间输入"
}
// 设置完
//获取基础数据
var canvas = document.createElement("canvas");
Dom.appendChild(canvas);
var ctx = canvas.getContext("2d");
var caseInfo = caseInfoArray;
var dateText = dateTextArray;
//获得并设置canvas标签的最小宽高,然后返回canvas中,绘图区域坐标
var setWidthWithHeight = function (Dom, canvas) {
//在dojo中,用aspect.after改造
// window.onresize,每次触发事件时重置一次,并且绘制一次
//获得画布区域的宽度和高度,并重置
if (Dom.clientWidth < 700) {
canvas.width = 700;
Dom.style.overflowX = "auto";
} else {
canvas.width = Dom.clientWidth;
}
if (Dom.clientHeight < 250) {
canvas.height = 250;
Dom.style.overflowY = "auto";
} else {
canvas.height = Dom.clientHeight;
}
//坐标轴区域
//注意,实际画折线图区域还要比这个略小一点
return {
x: 60 - 0.5, //坐标轴在canvas上的left坐标
y: 40 - 0.5, //坐标轴在canvas上的top坐标
maxX: canvas.width - 60.5, //坐标轴在canvas上的right坐标
maxY: canvas.height - 40.5 //坐标轴在canvas上的bottom坐标
};
}
// 绘制x、y坐标轴(不包含刻度)
var drawAxis = function (ctx, axis) {
ctx.beginPath();
ctx.lineWidth = 1;
ctx.strokeStyle = color.xyAxisLine;
ctx.moveTo(axis.x, axis.maxY);
ctx.lineTo(axis.x, axis.y);
ctx.lineTo(axis.x - 5, axis.y + 5);
ctx.moveTo(axis.x, axis.y);
ctx.lineTo(axis.x + 5, axis.y + 5);
ctx.stroke();
// 再画X轴
ctx.beginPath();
ctx.lineWidth = 1;
ctx.strokeStyle = color.xyAxisLine;
ctx.moveTo(axis.x, axis.maxY);
ctx.lineTo(axis.maxX, axis.maxY);
ctx.lineTo(axis.maxX - 5, axis.maxY + 5);
ctx.moveTo(axis.maxX, axis.maxY);
ctx.lineTo(axis.maxX - 5, axis.maxY - 5);
ctx.stroke();
// 写y轴原点的数字(注意,虽然是坐标原点,但这个是y轴的)
ctx.font = font.yScale;
ctx.textAlign = "right";
ctx.fillStyle = color.referenceLine;
// 设置字体内容,以及在画布上的位置
ctx.fillText("0", axis.x - 5, axis.maxY);
}
// 获得Y轴的最大尺度
var getMAXrectY = function (caseInfo) {
var theMaxCaseInfo = 0;
//用于获取最大值
caseInfo.forEach(function (item) {
if (item > theMaxCaseInfo) {
theMaxCaseInfo = item;
}
});
//返回计算出的最大数字
return (function (str) {
var number = null;
//用于计量坐标轴y轴的最大数字
if (str[0] == 1) {
if (str[0] + str[1] >= 18) {
number = '20';
} else {
if (Number(str[1]) % 2) {
number = str[0] + String(Number(str[1]) + 1);
} else {
number = str[0] + String(Number(str[1]) + 2);
}
}
for (let i = 2; i < str.length; i++) {
number += '0';
}
} else {
number = String(Number(str[0]) + 1);
for (let i = 1; i < str.length; i++) {
number += '0';
}
}
return number;
})(String(theMaxCaseInfo));
}
//划线和确定单元格的逻辑在这里,逻辑确定好后是将单元格放在rectYArray这个数组中
var getDrawYLineLaw = function (MAXrectY) {
var rectYArray = [];
//当最大案件数小于等于10时,以2为一格
if (MAXrectY <= 10) {
console.log(MAXrectY);
rectYArray.push(2, 4, 6, 8, 10);
} else {
var str = String(MAXrectY);
var zeroNumber = MAXrectY.length - 2;
// 用于填充的0的数量,原因是判断时只判断前一位或两位
var fillZero = String(Math.pow(10, zeroNumber)).replace('1', '');
// 然后先判断首位,如果是1,则最大是之前获取到的最大数值,以2/格为单位
// 如果是2~5,则以5/格为单位
// 如果是6~9,则以10/格为单位
if (Number(str[0]) === 1) {
for (var i = 0; i < Number(str[0] + str[1]); i = i + 2) {
rectYArray.push(i + 2 + fillZero);
}
} else if (Number(str[0]) >= 2 && Number(str[0]) < 6) {
for (var i = 0; i < Number(str[0] + str[1]); i = i + 5) {
rectYArray.push(i + 5 + fillZero);
}
} else if (Number(str[0]) >= 6 && Number(str[0]) < 10) {
for (var i = 0; i < Number(str[0] + str[1]); i = i + 10) {
rectYArray.push(i + 10 + fillZero);
}
}
}
console.log(rectYArray);
return rectYArray;
}
//画y轴参考线和坐标数字
var DrawYLine = function (ctx, axis, YLineLaw) {
// 在得到单元格后,开始绘图,绘出y轴上每个单元格的直线
// Y轴参考线的x坐标是从0到axis.maxX - 10
var yMaxPoint = axis.y + 20; //最上面的y轴坐标
var xMaxPoint = axis.maxX - 10; //最右边的x轴坐标
ctx.strokeStyle = color.referenceLine;
for (let i = 0; i < YLineLaw.length; i++) {
ctx.beginPath();
// 当前绘制线条的y坐标
let yLine = (YLineLaw[i] - YLineLaw[0] ) / YLineLaw[YLineLaw.length - 1] * (axis.maxY - yMaxPoint) + yMaxPoint;
ctx.moveTo(axis.x, yLine);
ctx.lineTo(xMaxPoint, yLine);
ctx.stroke();
//绘完线条写文字
ctx.font = font.yScale;
ctx.textAlign = "right";
ctx.fillStyle = color.yScale;
// 设置字体内容,以及在画布上的位置
ctx.fillText(YLineLaw[YLineLaw.length - i - 1], axis.x - 5, yLine + 5);
}
}
//绘制数据
var DrawData = function (ctx, axis, caseInfo, YLineMax, dateText) {
// 折线绘图区域的x轴从x=0开始绘图,绘制的最右边是axis.maxX-20(参考线是-10)
// y轴是从y=0开始绘制,绘制的最顶部是最顶部参考线的位置(axis.y+20)
// 参数依次为:绘图对象ctx,坐标轴区域坐标axis,绘图用的数据caseInfo,Y轴最大值YLineMax,x轴横坐标文字dateText
var rect = {
left: axis.x, //折线绘图区域的left
top: axis.y + 20, //折线绘图区域的top
height: axis.maxY - axis.y - 20, //折线绘图区域的bottom
width: axis.maxX - 20 - axis.x //折线绘图区域的right
};
//绘制数据的折线
ctx.beginPath();
ctx.strokeStyle = color.dataLine;
ctx.lineWidth = dataLineWidth;
var firstPoint = {
x: rect.left + 0.5, //之所以+0.5,是因为rect.x来源于axis.x表示划线,因此少了0.5px宽,这里要弥补上
y: rect.top + (1 - caseInfo[0] / YLineMax) * rect.height + 0.5
}
// console.log(firstPoint);
ctx.moveTo(firstPoint.x, firstPoint.y);
for (let i = 0; i < caseInfo.length; i++) {
var point = {
x: rect.left + i / 11 * rect.width + 0.5,
y: rect.top + (1 - caseInfo[i] / YLineMax) * rect.height
};
ctx.lineTo(point.x, point.y);
//写x轴坐标文字
ctx.font = font.xScale;
ctx.textAlign = "center";
ctx.fillStyle = color.xScale;
ctx.fillText(dateText[i], point.x, rect.top + rect.height + 15);
}
ctx.stroke();
}
//错误检查
var inputError = function () {
//不是数组
if (!(caseInfo instanceof Array)) {
return error.errorCaseInfo;
}
// 数组数目不足12,用0填充靠前的部分
// 大于12,移除前面的部分
if (caseInfo.length < 12) {
while (caseInfo.length < 12) {
caseInfo.unshift(0);
}
} else if (caseInfo.length > 12) {
while (caseInfo.length > 12) {
caseInfo.shift(0);
}
}
//判断数组每个元素的类型是否是number或者能否转换为number
var checkElementType = caseInfo.every(function (item) {
//如果强制转换后为NaN,那么
if (typeof item !== "number") {
return false;
} else {
return true;
}
})
if (!checkElementType) {
return error.errorCaseTpye;
}
// 月份应该是字符串,如2016/2
// 如果被/分割拆分后数组长度不是2,或者拆分后元素0的长度不是4,或者拆分后元素1的长度不是1或2
// 或者parseInt转换后为NaN
var checkDateText = dateText.every(function (item) {
var date = item.split("/");
if (date.length !== 2 || date[0].length !== 4 || date[1].length < 1 || date[1].length > 2 ||
isNaN(parseInt(date[0])) || isNaN(parseInt(date[1]))) {
return false;
} else {
return true;
}
})
if (!checkDateText) {
return error.errorDate
}
return false;
}
//绘图函数,绘制时调用本函数
this.toDraw = function () {
// 设置canvas的Dom的宽高
var axis = setWidthWithHeight(Dom, canvas);
// 绘制x、y坐标轴(不包含刻度)
drawAxis(ctx, axis);
//如果检测返回false
// 如果没问题,则返回false,否则值可以隐式转换为true
var errorMessage = inputError();
if (errorMessage) {
ctx.font = "Bold 20px Arial";
ctx.textAlign = "center";
ctx.fillStyle = color.errorMessage;
ctx.fillText(errorMessage, (axis.x + axis.maxX) / 2, (axis.y + axis.maxY) / 2);
return;
}
// 获得Y轴的最大尺度
var MAXrectY = getMAXrectY(caseInfo);
// 获得y轴划参考线规则
var YLineLaw = getDrawYLineLaw(MAXrectY);
// 绘制Y轴参考线
DrawYLine(ctx, axis, YLineLaw);
// 绘制数据
DrawData(ctx, axis, caseInfo, YLineLaw[YLineLaw.length - 1], dateText);
};
//启动本实例时绘图一次
this.toDraw();
var self = this;
//浏览器窗口大小变化时,绘图一次
//潜在缺点:会覆盖其他的这个方法,建议用jquery的$(window).resize来替代
window.onresize = function () {
self.toDraw();
};
}
</script>
</body>
</html>