??這回算是真明白了什么叫"林子大了什么鳥都有!"之前就有聽說面試騙代碼的情況,但也僅僅只是聽說。這回是真親身遇到了。來來來,自帶小板凳,準備好瓜子。好好看看我被騙的經(jīng)歷。順便也看看使用原生Canvas繪制餅圖,使用插件(比如Echart)也就分分鐘的事情,但多了解一些原生的東西,總不會有錯的。
??正文開始.....
我是不是被騙代碼了???
??還是前段時間面試時發(fā)生的事情。3月21號晚八點,此時心態(tài)已處于第三階段(詳情可查看面試總結(jié)),突然收到一封郵件,如下:
??
??
??巧了,3月22有兩場面試,還是兩家我覺得不錯的公司(南方+、愛范兒科技),我誤以為就是這兩家其中一家的測試。
??熬到22號兩點,餅圖倒是畫出來了,只是線條還有很大問題。當(dāng)時的想法是通過計算位置,使用div來畫線條。這有兩個問題:一是無法實現(xiàn)拆線;二是會不準。因為白天還有面試,所以就直接發(fā)了半成品過去,并詢問是什么公司。對話如下:
??
??
??居然還沒約面試,只有想會是哪家公司呢?反正沒往騙代碼上想!3月23,繼續(xù)嘗試了一下,線條也通過canvas來繪制,解決了之前的兩個問題,還處理考慮擠一起的需求,算得上已經(jīng)實現(xiàn)需求。效果如下:
??3月23晚上,發(fā)送過去。3月25晚上,收到回復(fù)確是這樣:
你好,舒同學(xué)??戳四愕淖髌罚芊裨偻晟埔幌??因為這是仿支付寶的餅圖,所以希望是適配于移動設(shè)備的,另外APP里的Webview好像要在6.0以上才支持es6語法,想把它轉(zhuǎn)成es5語法的,麻煩舒同學(xué)了
??到這里我才開始覺得不對勁。 為啥要ES6轉(zhuǎn)ES5,又體現(xiàn)不了什么技術(shù)能力,又不是實際使用;手機適配的問題,我這大小是可配置的并沒有寫死 。所以,馬上詢問是什么公司。回復(fù)如下:
林老師,測試題的目的應(yīng)該就是了解一下應(yīng)聘者的能力。我想,題目做到現(xiàn)在,我大概的代碼風(fēng)格和技術(shù)能力,你應(yīng)該了解了。
請問貴公司是?
??然后。。。然后就再沒收到回復(fù)。。。。
??這里我才想到自己是不是被騙代碼了?可現(xiàn)在都不敢相信呀,這種代碼也有人騙么?可如果不是,難道我這代碼寫得太low了,所以連個面試機會都拿不到?
??所以,這里貼上代碼,分享一下生Canvas繪制餅圖的想法,同時也讓大家?guī)兔纯?,這樣的代碼能不能得到一次面試機會呀![笑哭]*10
??
??
??
餅圖繪制代碼
稍微有些難的幾個點:
- 會用到三角函數(shù)各種計算坐標(biāo),如果早已忘記,需要回頭看看;
- 如何處理點會擠在一的情況;
- canvas的畫弧方法arc的0度是從笛卡坐標(biāo)的90度開始,角度不一致需要區(qū)分;
??下面是完整的代碼,有完成的注釋,代碼比注釋還多。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>餅圖</title>
</head>
<body>
<script>
/**
* 繪制餅圖函數(shù)
* 使用到的ES6語法有函數(shù)默認參數(shù)、解構(gòu)、字符模板
* 如果不熟悉,可以看看阮老師的《ECMAScript 6 入門》
* 網(wǎng)址 http://es6.ruanyifeng.com/
* 函數(shù)的默認參數(shù)
* r 圓環(huán)的圓半徑 data 數(shù)據(jù)項
* width 圖表寬度 height 圖表高度
*/
function addPie({r = 100,width = 450,height = 400,data = []} = {}) {
let cns = document.createElement('canvas'); //創(chuàng)建一個canvas
let ctx = cns.getContext('2d'); //獲取canvas操作對象
let w = width;
let h = height; //將width、height賦值給w、h
let originX = w / 2; //原點x值
let originY = h / 2; //原點y值
let points = []; //用于保存數(shù)據(jù)項線條起點坐標(biāo)
let leftPoints = []; //保存在左邊的點
let rightPoints = []; //保存在右邊的點,分出左右是為了計算兩點垂直間距是否靠太近
let fontSize = 12; //設(shè)置字體大小,像素
//total保存總花費,用于計算數(shù)據(jù)項占比
let total = data.reduce(function(v, item) {
return v + item.cost;
}, 0)
/**
* sAngel 起始角弧度
* arc方法繪制弧線/圓時,弧的圓形的三點鐘位置是 0 度
* 也就是0弧度對應(yīng)笛卡坐標(biāo)的90度位置
* 為了讓餅圖從笛卡坐標(biāo)的0度開始
* 起始角弧度需要設(shè)置為-.5 * Math.PI
*/
let sAngel = -.5 * Math.PI;
let eAngel = -.5 * Math.PI; //結(jié)束角弧度,初始值等于sAngel
let aAngel = Math.PI * 2; //整圓弧度,用于計算數(shù)據(jù)項弧度
let pointR = r + 10; //計算線條起始點的半徑
let minPadding = 30; //設(shè)置數(shù)據(jù)項兩點最小間距
//設(shè)置canvas和畫布大小
cns.width = ctx.width = w;
cns.height = ctx.height = h;
let cAngel; //數(shù)據(jù)項中間位置的弧度值,用于計算線條起始點
for (let i = 0, len = data.length; i < len; i++) { /* 繪制不同消費的份額 */
/**
* 計算結(jié)束角弧度
* 等于上一項數(shù)據(jù)起始弧度值(sAngel)
* 加數(shù)據(jù)占比(data[i].cost/total)乘以整圓弧度(aAngel)
*/
eAngel = sAngel + data[i].cost/total * aAngel ;
//畫弧
_drawArc(ctx, {
origin: [originX, originY],
color: data[i].color,
r,
sAngel,
eAngel
})
/**
* 計算cAngel值
* cAngel是用于計算線條起始點
* 等于當(dāng)前數(shù)據(jù)項的起始弧度:sAngel
* 加上當(dāng)前數(shù)據(jù)項所占弧度的一半:(eAngel - sAngel) / 2
* 因為arc方法0弧度對應(yīng)笛卡坐標(biāo)的90度位置,我們讓sAngel從 -0.5 * Math.PI開始的
* 所以cAngel還要加 0.5 * Math.PI
*/
cAngel = 0.5 * Math.PI + sAngel + (eAngel - sAngel) / 2;
/**
* 保存每個數(shù)據(jù)項線條的起始點
* 根據(jù)三角函數(shù)
* 已知半徑/斜邊長:pointR, 通過正弦函數(shù)可以計算出對邊長度
* 原點x坐標(biāo)加對邊長度,就是線條起始點x坐標(biāo)
* 通過余弦函數(shù)可以計算出鄰邊長度
* 原點y坐標(biāo)減鄰邊長度,就是線條起始點y坐標(biāo)
*/
points.push([originX + Math.sin(cAngel) * pointR, originY - Math.cos(cAngel) * pointR])
sAngel = eAngel; //設(shè)置下一數(shù)據(jù)項的起始角度為當(dāng)前數(shù)據(jù)項的結(jié)束角度
}
for (let i = 0, len = points.length; i < len; i++) { /* 繪制起始點的小圓點,并分出左右 */
// 繪制起始點的小圓點
_drawArc(ctx, {
origin: points[i],
color: data[i].color,
r: 2
})
if (points[i][0] < originX) { /* x坐標(biāo)小于原點x坐標(biāo),在左邊 */
leftPoints.push({
point: points[i],
/**
* top標(biāo)記坐標(biāo)是否在y軸正方向(是不是在上方)
* 用于判斷當(dāng)兩點擠在一起時,是優(yōu)先向下還是向上移動線條線束點坐標(biāo)
*/
top: points[i][1] < originY, //y坐標(biāo)小于原點y坐標(biāo)。表示在上方
/**
* endPoint保存線條結(jié)束點坐標(biāo)
* y值不變,在左邊時結(jié)束點x為零
*/
endPoint: [0, points[i][1]]
});
} else { /* 否則在右邊*/
rightPoints.push({
point: points[i],
top: points[i][1] < originY, //y坐標(biāo)小于原點y坐標(biāo)。表示在上方
endPoint: [w, points[i][1]] //y值不變,在右邊時結(jié)束點x為圖表寬度w
});
}
}
_makeUseable(rightPoints); //處理右邊擠在一起的情況
_makeUseable(leftPoints.reverse(), true); //處理左邊擠在一起的情況
leftPoints.reverse(); //為什么要翻轉(zhuǎn)一下,看_makeUseable函數(shù)
let i = 0;
for (let j = 0, len = rightPoints.length; j < len; j++) { // 繪制右側(cè)線條、文本
_drawLine(ctx, {data:data[i], point:rightPoints[j], w, direct: 'right'});
i++;
}
for (let j = 0, len = leftPoints.length; j < len; j++) { // 繪制左側(cè)線條、文本
_drawLine(ctx, {data:data[i], point:leftPoints[j], w});
i++;
}
/* 再繪制一個圓蓋住餅圖,實現(xiàn)圓環(huán)效果 */
_drawArc(ctx, {
origin: [originX, originY],
r: r / 5 * 3
})
document.body.appendChild(cns); /* 添加到body中 */
/* 畫弧函數(shù) */
function _drawArc(ctx, {color = '#fff',origin = [0, 0],r = 100,sAngel = 0, eAngel = 2 * Math.PI}) {
ctx.beginPath(); //開始
ctx.strokeStyle = color; //設(shè)置線條顏色
ctx.fillStyle = color; //設(shè)置填充色
ctx.moveTo(...origin); //移動原點
ctx.arc(origin[0], origin[1], r, sAngel, eAngel); //畫弧
ctx.fill(); //填充
ctx.stroke();//繪制已定義的路徑,可省略
}
/* 畫線和文本 函數(shù) */
function _drawLine (ctx, {direct='left',data={},point={},w = 200}) {
ctx.beginPath(); //開始
ctx.moveTo(...point.point); //移動畫筆到線條起點
ctx.strokeStyle = data.color; //設(shè)置線條顏色
if (point.turingPoint) //存在折點
ctx.lineTo(...point.turingPoint); //畫一條到折點的線
ctx.lineTo(...point.endPoint);//畫一條到結(jié)束點的線
ctx.stroke();//繪制已定義的路徑
ctx.font = `${fontSize}px 微軟雅黑`; //設(shè)置字體相關(guān)
ctx.fillStyle = '#000'; //設(shè)置字體顏色
ctx.textAlign = direct;//設(shè)置文字對齊方式
//繪制數(shù)據(jù)項花費文字,垂直上移兩個像素
ctx.fillText(data.cost,direct === 'left'?0:w, point.endPoint[1] - 2);
//繪制數(shù)據(jù)項名稱,垂直下移fontSize個像素
ctx.fillText(data.category, direct === 'left'?0:w, point.endPoint[1] + fontSize);
}
function _isUseable(arr) { // 判斷是否會有數(shù)據(jù)擠在一起(兩點最小間距是否都大于等于minPadding)
if (arr.length <= 1)
return true;
return arr.every(function(p, index, arr) {
if (index === arr.length-1) {
//因為是當(dāng)前項和下一項比較,所以index === arr.length-1直接返回true
return true;
} else {
/**
* 判斷當(dāng)前數(shù)據(jù)項結(jié)束點:p.endPoint[1]
* 和下一數(shù)據(jù)項結(jié)束點垂直間距是否大于等于最小間距:minPadding
* 只有數(shù)據(jù)線條結(jié)束點垂直間距大于等于最小間距,才會返回true
*/
return arr[index + 1].endPoint[1] - p.endPoint[1] >= minPadding;
}
})
}
function _makeUseable(arr, left) {// 處理擠在一起的情況
let diff, turingAngel, x, maths = Math.sin,diffH, l;
/**
* 這里的思路是
* 如果數(shù)據(jù)是非可用的(會擠在一起,_isUseable判斷)
* 就一直循環(huán)移動數(shù)據(jù),直至可用
* 數(shù)據(jù)項過多時會出現(xiàn)死循環(huán)
* 因為需求上說數(shù)據(jù)項不會過多,并且還要讓大家?guī)臀铱纯茨懿荒塬@得面試機會
* 所以這里不做修改
* 可能會有更好的算法,我這魚木腦袋只想到這種的
* 歡迎大家提供更好的思路或算法
*/
while (!_isUseable(arr)) { //每次循環(huán)處理一次,直至數(shù)據(jù)不會擠在一起
for (let i = 0, len = arr.length - 1; i < len; i++) { //遍歷數(shù)組
diff = arr[i + 1].endPoint[1] - arr[i].endPoint[1]; //計算兩點垂直間距
if (diff < minPadding) { //小于最小間距,表示會擠到一起
if (arr[i].top && arr[i + 1].top) { //是在上部的點,向上移動
/**
* 判斷當(dāng)前的點是否還可以向上移動
* 上方第一個點最往上只可以移動到y(tǒng)值為0
* 之后依次最往上只能移動動y值為:i * minPadding
* 所以下面判斷應(yīng)該是:arr[i].endPoint[1] - (minPadding - diff) > i * minPadding
*/
/**
* 上面左邊leftPoints的點需要翻轉(zhuǎn)一下的原因是
* 左邊leftPoints的點最上面的點是排在最后的
*/
if (arr[i].endPoint[1] - (minPadding - diff) > 0 && arr[i].endPoint[1] > i * minPadding) {
//當(dāng)前點還能向上移動
//向上移動到不擠(滿足最小間距)
arr[i].endPoint[1] = arr[i].endPoint[1] - (minPadding - diff);
} else {
//當(dāng)前點不向上移動到滿足最小間距的位置
//先把當(dāng)前點移動到能夠移動的最上位置
arr[i].endPoint[1] = i * minPadding;
//再把下個點移動,使?jié)M足最小間距
arr[i + 1].endPoint[1] = arr[i + 1].endPoint[1] + (minPadding - diff);
}
} else {
//是在下部的點,向下移動
/**
* 判斷當(dāng)前點的下個點是否還可以向下移動
* 下方最后一個點最往下只可以移動到y(tǒng)值為h,即圖表高度
* 之前的點依次最往下只能移動動y值為:h - (len - i - 1) * minPadding
* 所以下面判斷應(yīng)該是:arr[i + 1].endPoint[1] + (minPadding - diff) < h - (len - i - 1) * minPadding
*/
if (arr[i + 1].endPoint[1] + (minPadding - diff) < h && arr[i + 1].endPoint[1] < h - (len - i - 1) * minPadding) {
//當(dāng)前點的下個點還能向下移動
//當(dāng)前點的下個點向下移動到不擠(滿足最小間距)
arr[i + 1].endPoint[1] = arr[i + 1].endPoint[1] + (minPadding - diff)
} else {
//當(dāng)前點的下個點不能向下移動
//先把當(dāng)前點的下個點向下移動能夠移動的最下位置
arr[i + 1].endPoint[1] = h - (len - i - 1) * minPadding;
//再把當(dāng)前點移動,使?jié)M足最小間距
arr[i].endPoint[1] = arr[i].endPoint[1] - (minPadding - diff);
}
}
break; //每次移動完成直接退出循環(huán),判斷一次是否已經(jīng)不擠
}
}
}
/**
* 遍歷已經(jīng)可用的數(shù)據(jù)
* 起點和結(jié)束點不在同一水平線上
* 需要設(shè)置折點
* 這里通過設(shè)置折線角度,計算出折點位置
* 回頭一想,其實可以用更簡單的方法,想復(fù)雜了
*/
for (let i = 0, len = arr.length; i < len; i++) {
//起點和結(jié)束點y值不等,則不在同一水平線,需要設(shè)置折點
if (arr[i].point[1] !== arr[i].endPoint[1]) {
turingAngel = 1 / 3 * Math.PI; //默認折線角度設(shè)置60度
//計算出起點和結(jié)束點高度差
diffH = arr[i].endPoint[1] - arr[i].point[1];
//計算出起點和結(jié)束點水平距離l
l = Math.abs(arr[i].endPoint[0] - arr[i].point[0]);
/**
* x 這里的本意是
* 想計算出折點和起始點的水平距離x
* 因為起始點到折點的水平距離
* 不能大于起始點到結(jié)束的水平距離-40(留40放文字)
* 通過x可以確定折點的x坐標(biāo)值
* 所以已知對邊和角度,應(yīng)該使用正切函數(shù)求鄰邊邊長
* 這里卻使用了正弦求了斜邊
*/
x = Math.abs(maths(turingAngel) * diffH);
/**
* 如果始點到折點的水平距離
* 大于起始點到結(jié)束的水平距離-40(留40放文字)
* 減小角度,計算新折點
*/
while (x > (l - 40)) {
turingAngel /= 2;
x = maths(turingAngel) * (arr[i].endPoint[1] - arr[i].point[1]);
}
//通過x可以確定折點的x坐標(biāo)值,y坐標(biāo)就是結(jié)束點的y坐標(biāo)
arr[i].turingPoint = [arr[i].point[0] + (left ? -x : x), arr[i].endPoint[1]]
}
}
}
}
//調(diào)用繪圖函數(shù)
addPie({
data: [{
cost: 4.94,
category: '通訊',
color: "#e95e45",
}, {
cost: 4.78,
category: '服裝美容',
color: "#20b6ab",
}, {
cost: 4.00,
category: '交通出行',
color: "#ef7340",
}, {
cost: 3.00,
category: '飲食',
color: "#eeb328",
}, {
cost: 49.40,
category: '其他',
color: "#f79954",
}, {
cost: 28.77,
category: '生活日用',
color: "#00a294",
}]
})
</script>
</body>
</html>
寫在最后
??因為是單個測試題目,所以沒有用圖表庫。之所以沒用SVG去實現(xiàn),是因為之前只有接觸過canvas。不過,后續(xù)真可以考慮使用svg來實現(xiàn)一下。