canvas-credit-score
Version:
Canvas实现类似支付宝的信用评分
655 lines (616 loc) • 18.1 kB
text/typescript
import utils from './utils';
// 默认参数的类型
interface StyleParams {
line: {
initColor: string; // 初始化颜色
activeColor: string; // 高亮的颜色
width: number; // 线条的粗细
};
dashLine: {
initColor: string; // 初始化颜色
activeColor: string; // 高亮的颜色
width: number; // 线条的粗细
};
outerText: {
// 外环文本
fontSize: number;
color: string;
};
innerText: {
score: {
fontSize: number;
color: string;
};
level: {
fontSize: number;
color: string;
};
date: {
fontSize: number;
color: string;
fontWeight: string;
};
};
}
interface DefaultParams {
arrowImg: string; // 箭头 base64
x: number; // 圆心的x坐标
y: number; // 圆心的y坐标
startAngle: number; // 开始的角度
endAngle: number; // 结束的角度
currentAngle: number; // 当前的角度
scoreStart: number; // 起步分
scoreTarget: number; // 目标分
scoreMin: number; // 最低分
scoreMax: number; // 最高分
scoreEvaDate: string; // 评估日期
segAngle: number; // 总角度分成几等分
outerTextSeg: number; // 外围文本分成几份
style: StyleParams; // 样式
scoreLevelText: string; // 信用等级文本
}
class CreditScore {
public canvas: HTMLCanvasElement;
public ctx: CanvasRenderingContext2D;
public ratio: number;
public options: DefaultParams;
public pointImg: HTMLImageElement;
public el: HTMLElement | null;
public isDrawComplete: boolean;
public cacheParams: {};
constructor(el: string, options = { arrowImg: '' }) {
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d') as any;
this.ratio = window.devicePixelRatio; // 像素比
this.pointImg = new Image(); // 新建一个图片实例
this.isDrawComplete = false; // 画布是否渲染完毕
this.el = document.getElementById(el);
this.cacheParams = {};
if (options && options.arrowImg) {
this.pointImg.src = options.arrowImg;
} else {
this.pointImg.src =
'data:image/svg+xml;base64,PHN2ZyAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgd2lkdGg9IjE2cHgiIGhlaWdodD0iMjRweCI+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiAgZmlsbD0icmdiKDI1NSwgMjU1LCAyNTUpIiBkPSJNOC4wMDAsMjQuMDA1IEMzLjU4MiwyNC4wMDUgLTAuMDAwLDIwLjUyNSAtMC4wMDAsMTYuMjMwIEMtMC4wMDAsMTEuOTM2IDguMDAwLC0wLjAwNSA4LjAwMCwtMC4wMDUgQzguMDAwLC0wLjAwNSAxNS45OTksMTEuOTM2IDE1Ljk5OSwxNi4yMzAgQzE1Ljk5OSwyMC41MjUgMTIuNDE4LDI0LjAwNSA4LjAwMCwyNC4wMDUgWk04LjAwMCwxMi4wNjUgQzUuNjI1LDEyLjA2NSAzLjcwMCwxMy45MzQgMy43MDAsMTYuMjM5IEMzLjcwMCwxOC41NDQgNS42MjUsMjAuNDEzIDguMDAwLDIwLjQxMyBDMTAuMzc1LDIwLjQxMyAxMi4zMDAsMTguNTQ0IDEyLjMwMCwxNi4yMzkgQzEyLjMwMCwxMy45MzQgMTAuMzc1LDEyLjA2NSA4LjAwMCwxMi4wNjUgWiIvPjwvc3ZnPg==';
}
this.options = utils.mergeDeep(
{
x: 0,
y: 0,
startAngle: 165, // 画布的起点
endAngle: 375, // 画布的结束点
currentAngle: 165, // 当前的角度
scoreStart: 450, // 起步分
scoreTarget: 770, // 目标分
scoreMin: 450, // 最低分
scoreMax: 850, // 最高分
scoreEvaDate: '评估日期:2019-04-01', // 评估日期
scoreLevelText: '', // 信用等级文字
segAngle: 42, // 总角度分成几等分,
outerTextSeg: 5, // 数字范围等分
style: {
line: {
// 线条颜色控制
initColor: 'rgba(255, 191, 150, 0.5)', // 初始化颜色
activeColor: '#fff', // 高亮的颜色
width: 1 // 线条的出息
},
dashLine: {
initColor: 'rgba(255, 191, 150, 0.5)', // 初始化颜色
activeColor: '#fff', // 高亮的颜色
width: 1 // 线条的粗细
},
outerText: {
// 外环文本
fontSize: 12,
color: '#fff'
},
innerText: {
score: {
fontSize: 36,
color: '#fff'
},
level: {
fontSize: 16,
color: '#fff'
},
date: {
fontSize: 12,
color: '#f2f2f2',
fontWeight: 'normal'
}
}
}
},
options
);
this.initElm();
}
// 构建元素并添加到指定区域
private initElm(): void {
if (this.el) {
this.el.appendChild(this.canvas);
const { height, width } = this.el.getBoundingClientRect();
console.log('height, width: ', height, width);
this.canvasSize(width, height);
this.options = {
...this.options,
x: height / 2,
y: width / 2
};
this.fixRangeValue(this.options);
// 缓存实例初始化的参数
this.cacheParams = this.options;
this.init();
} else {
throw new Error('请传入一个渲染区域的ID');
}
}
// 做一些常规的判断,比如传入的最小值大于最大值,起始分和起始角度同步最小分和最小起始角度
private fixRangeValue(options: DefaultParams): void {
this.options = {
...options,
scoreStart: this.options.scoreMin, // 重置开始计算的分很为最小分
scoreTarget:
this.options.scoreTarget > this.options.scoreMax
? this.options.scoreMax
: this.options.scoreTarget,
currentAngle: this.options.startAngle
};
}
// 初始化canvas的一些参数
protected init(options = {}): void {
this.options = utils.mergeDeep(this.options, options);
this.fixRangeValue(this.options);
}
// 画布大小
protected canvasSize(width: number = 375, height: number = 375): void {
this.canvas.width = width * this.ratio;
this.canvas.height = height * this.ratio;
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
this.ctx.scale(this.ratio, this.ratio);
}
/**
*
* @param endAngle 结束的角度
* @param startAngle 开始的角度
* @param segAngle 拆成多少等分
*/
private stepAngleCalc(
endAngle: number,
startAngle: number,
segAngle: number
): number {
return (endAngle - startAngle) / segAngle;
}
// 清空画布
/**
* [clearCanvas description]
*
* @param {number} width [清除的宽度]
* @param {number} height [清除的高度]
*
* @return {[type]} [没有返回]
*/
private clearCanvas(width: number = 0, height: number = 0): void {
this.ctx.clearRect(0, 0, width, height);
}
/**
*
* @param 线条旋转这些
* @param 圆的三要素,x,y,半径
*/
private drawCircleLine(
{ color = '#fff' }: { color: string },
{
x = 0,
y = 0,
radius = 0,
startAngle,
endAngle
}: {
x: number;
y: number;
radius: number;
startAngle?: number;
endAngle?: number;
}
): void {
this.ctx.beginPath();
this.ctx.arc(
x,
y,
radius,
utils.getRadian(startAngle ? startAngle : this.options.startAngle),
utils.getRadian(endAngle ? endAngle : this.options.endAngle)
);
this.ctx.strokeStyle = color;
this.ctx.lineCap = 'round';
this.ctx.lineWidth = 1;
this.ctx.stroke();
}
// 绘制外围文本
/**
*
* @param x 坐标的x
* @param y 坐标的y
* @param text 展示的文本
* @param fontSize 文本的大小
* @param color 文本的颜色
* @param fontWeight 文本的粗细
*/
private drawOuterText(
x: number,
y: number,
text: string,
fontSize: number = 28,
color: string = '#fff',
fontWeight: string = 'normal'
): void {
this.ctx.beginPath();
this.ctx.fillStyle = color;
this.ctx.font = `${fontWeight} ${fontSize}px Microsoft yahei`;
this.ctx.textBaseline = 'ideographic';
this.ctx.textAlign = 'left';
this.ctx.fillText(text, x - this.ctx.measureText(text).width / 2, y);
}
// 画外围文本线
private drawOuterTextLine(
{
outerText: { textArr, textAngelArr, textLenght }
}: {
outerText: {
textArr: number[];
textAngelArr: number[];
textLenght: number;
};
},
{ x, y, radius }: { x: number; y: number; radius: number }
): void {
// 最外围文字层
for (let index = 0; index < textLenght; index++) {
let angle = utils.getRadian(textAngelArr[index]);
const { x1, y1 } = utils.getRadiusPoint(x, y, radius, angle);
this.drawOuterText(
x1,
y1,
'' + textArr[index],
this.options.style.outerText.fontSize,
this.options.style.outerText.color
);
}
}
// 画小圆点
/**
*
* @param x x坐标
* @param y y坐标
* @param radius 半径
* @param fillColor
* @param mode true:顺时针 false:逆时针
*/
private drawCircle(
x: number = 0,
y: number = 0,
radius: number = 1,
fillColor: string = '#fff',
mode: boolean = false
): void {
this.ctx.beginPath();
this.ctx.arc(x, y, radius, 0, 2 * Math.PI, mode);
this.ctx.fillStyle = fillColor;
this.ctx.fill();
}
/**
*
* @param x 坐标的x
* @param y 坐标的y
* @param text 展示的文本
* @param fontSize 文本的大小
* @param color 文本的颜色
* @param fontWeight 文本的粗细
*/
private drawInnerText(
x: number,
y: number,
text: number | string,
fontSize: number = 30,
color: string = '#fff',
fontWeight: string = 'bold'
): void {
this.ctx.save();
this.ctx.fillStyle = color;
this.ctx.font = `${fontWeight} ${fontSize}px Microsoft yahei`;
this.ctx.textAlign = 'center';
this.ctx.fillText(`${text}`, x, y);
this.ctx.textBaseline = 'ideographic';
this.ctx.restore();
}
// 画水滴
/**
*
* @param x 坐标x
* @param y 坐标y
* @param rotate 偏移的角度
*/
private waterDrop(x: number, y: number, rotate: number): void {
this.ctx.save();
this.ctx.translate(x, y);
this.ctx.rotate(utils.getRadian(rotate));
this.ctx.drawImage(this.pointImg, 0, 0, 8, 12);
this.ctx.restore();
}
// 移动水滴
/**
*
* @param x 坐标x
* @param y 坐标y
* @param radius 半径
* @param angle 移动的角度
*/
private moveWaterDrop(
x: number,
y: number,
radius: number,
angle: number
): void {
const { x1, y1 } = utils.getRadiusPoint(
x,
y,
radius,
utils.getRadian(angle)
);
// 为什么要加90°,是因为图片默认是垂直的,我们要扭正他,从坐标系的初始值开始
this.waterDrop(x1, y1, angle + 90);
}
// 画虚线圆弧
private drawCircleDashLine(
{ color = '#ccc' }: { color: string },
{
x,
y,
radius,
startAngle,
endAngle
}: {
x: number;
y: number;
radius: number;
startAngle?: number;
endAngle?: number;
}
): void {
this.ctx.beginPath();
for (let i = 1; i <= this.options.segAngle; i++) {
const { x1, y1 } = utils.getRadiusPoint(
x,
y,
radius,
utils.getRadian(
this.options.startAngle +
this.stepAngleCalc(
endAngle ? endAngle : this.options.endAngle,
startAngle ? startAngle : this.options.startAngle,
this.options.segAngle
) *
i
)
);
this.drawCircle(x1, y1, 1, color);
}
}
// 获取范围
/**
* @param type 'score'|'angel'
*/
protected getNumberRange(type: string): number {
if (type === 'score') {
return this.options.scoreMax - this.options.scoreMin;
}
if (type === 'angel') {
return this.options.endAngle - this.options.startAngle;
}
return 0;
}
/**
* @param text 传入分数值,没有传入则拆分范围为三等分给出默认级别
*/
protected getScoreLevelText(text?: string): string {
if (text) {
return text;
} else {
let per: number = Math.ceil(this.getNumberRange('score') / 4);
if (this.options.scoreStart <= this.options.scoreMin + per) {
return '信用中等';
}
if (
this.options.scoreStart > this.options.scoreMin + per &&
this.options.scoreStart <= this.options.scoreMin + per * 2
) {
return '信用良好';
}
if (
this.options.scoreStart > this.options.scoreMin + per * 2 &&
this.options.scoreStart <= this.options.scoreMin + per * 3
) {
return '信用优秀';
}
if (
this.options.scoreStart > this.options.scoreMin + per * 3 &&
this.options.scoreStart <= this.options.scoreMin + per * 4
) {
return '信用极高';
}
return '';
}
}
// 获取外部文本的对象集合
private getOuterTextCollection(): any {
let textGap: number = Math.ceil(
this.getNumberRange('score') / (this.options.outerTextSeg - 1)
);
// 不能完全整除的,最后一个值取范围最大值
let textArr: number[] = Array.from(
new Array(this.options.outerTextSeg),
(item, index): number =>
this.options.outerTextSeg - 1 === index
? this.options.scoreMax
: textGap * index + this.options.scoreMin
);
let textStepAngel: number =
this.getNumberRange('angel') / (textArr.length - 1);
let textAngelArr: number[] = textArr.map(
(item, index) => this.options.startAngle + textStepAngel * index
);
return {
textArr,
textLenght: textArr.length,
textAngelArr
};
}
// 绘制画布
public drawBaseMap(): void | boolean {
this.clearCanvas(this.canvas.width, this.canvas.height);
this.drawOuterTextLine(
{ outerText: this.getOuterTextCollection() },
{
x: this.options.x,
y: this.options.y,
radius: this.options.x * 0.82
}
);
this.drawCircleLine(
{
color: this.options.style.line.initColor
},
{
x: this.options.x,
y: this.options.y,
radius: this.options.x * 0.7
}
);
this.drawCircleDashLine(
{
color: this.options.style.dashLine.initColor
},
{
x: this.options.x,
y: this.options.y,
radius: this.options.x * 0.6666666
}
);
// 文字位置()
this.drawInnerText(
this.options.x,
this.options.y - 20,
this.options.scoreTarget - this.options.scoreStart < 5
? this.options.scoreTarget
: this.options.scoreStart,
this.options.style.innerText.score.fontSize,
this.options.style.innerText.score.color
);
this.drawInnerText(
this.options.x,
this.options.y + 5,
this.options.scoreLevelText
? this.options.scoreLevelText
: this.getScoreLevelText(),
this.options.style.innerText.level.fontSize,
this.options.style.innerText.level.color
);
this.drawInnerText(
this.options.x,
this.options.y + 25,
this.options.scoreEvaDate,
this.options.style.innerText.date.fontSize,
this.options.style.innerText.date.color,
this.options.style.innerText.date.fontWeight
);
// 覆盖实线
this.drawCircleLine(
{
color: this.options.style.line.activeColor
},
{
x: this.options.x,
y: this.options.y,
radius: this.options.x * 0.7,
startAngle: this.options.startAngle,
endAngle: this.options.currentAngle
}
);
// 覆盖虚线
this.drawCircleDashLine(
{
color: this.options.style.dashLine.activeColor
},
{
x: this.options.x,
y: this.options.y,
radius: this.options.x * 0.6666666,
startAngle: this.options.startAngle,
endAngle: this.options.currentAngle
}
);
// 移动小水滴
this.moveWaterDrop(
this.options.x,
this.options.y,
this.options.x * 0.6,
this.options.currentAngle
);
// 范围内无限render
if (this.options.scoreStart < this.options.scoreTarget) {
let stepMoveAngle = this.stepAngleCalc(
this.options.endAngle,
this.options.startAngle,
this.options.segAngle
);
// 每次移动角度的度数范围
this.options.currentAngle += stepMoveAngle;
// 文字变化
// 求出每次移动角度的度数范围要走多少分数
let stepScore =
(this.getNumberRange('score') * stepMoveAngle) /
this.getNumberRange('angel');
this.options.scoreStart = this.options.scoreStart + Math.round(stepScore);
if (this.options.scoreStart >= this.options.scoreTarget) {
this.options.scoreStart = this.options.scoreTarget;
}
// 求当前角度与分数角度的比较,当前累计的角度小于需要移动到目的地的角度就继续渲染
let stepAngle =
this.options.startAngle +
(this.getNumberRange('score') *
(this.options.scoreTarget - this.options.scoreMin)) /
this.getNumberRange('score');
if (this.options.currentAngle >= stepAngle) {
this.isDrawComplete = true;
this.ctx.save();
return false;
} else {
window.requestAnimationFrame(this.drawBaseMap.bind(this));
}
}
}
// 重新渲染画布
/**
* [refresh description]
*
* @param {[type]} delay [delay 推迟几秒重新渲染]
* @param {[type]} callback? [callback? 传入的回调函数]
*
* @return {[type]} [return description]
*/
refresh(delay: number = 1000, callback?: Function): void {
let st: any = setTimeout(() => {
clearTimeout(st);
if (this.isDrawComplete) {
this.isDrawComplete = false;
}
this.init(this.cacheParams);
this.drawBaseMap();
if (callback) callback();
}, delay);
}
}
export default CreditScore;