震驚!前端大佬都開始手寫 ECharts 了
一、自定義的必要性
============
繪製的底層是強大的, 我們所用的各端語言只是在現代 UI 追求的步伐中和用戶喜好的交互中求同存異,抽取封裝出自成個性風格的 UI 控件, 當然面對萬億級別的客戶各個平臺的 UI 庫出也不可能滿足所有的客戶需求, 當然一門語言的可制定性也意味着其強大, 幾乎每個平臺都提供了接口讓開發者創造其 UI 的可能性, 更可能的能滿足客戶需求。ECharts作爲前端強大的圖表 K 線等繪製工具可以說應有竟有, 無比風騷。但用戶和產品的需求永遠是一個庫滿足不了的。當然作爲技術人員自定義繪製也應該是需要掌握的技術。我們前端移動端作爲產品的排面就應該讓其獨具特色, 別具一格。所以自定義從我們的技術崗位、技術本身、億萬用戶不同需求... 出發," 自定義很必要 "。
二、ECharts
ECharts 使用過的夥伴們都知道極其的豐富和花裏胡哨了。對於庫的使用沒啥寫的吧?我們今天的目的是學會自己分析和寫出 ECharts 的效果, 而不是使用 Echarts 庫, 雖然我沒咋麼寫過前端, 有 API 咋們就能一步步往下走。如下:
折線圖
K 線圖
image.png
K 線圖
image.png
.... 當然還有很多。
三、畫布的認識
不同於 Android 以及 Flutter 等。Canvas 在 HTML5 中並不是實質的畫布。 元素本身並沒有繪製能力(它僅僅是圖形的容器) - 您必須使用腳本來完成實際的繪圖任務。getContext() 方法可返回一個對象,該對象提供了用於在畫布上繪圖的方法和屬性。HTML5 中可以通過 Canvas 標籤獲取 getContext("2d") 對象, 它提供了很多繪製的屬性和方法,可用於在畫布上繪製文本、線條、矩形、圓形等等。
1、畫布的創建
第一我們通過 getElementById 來獲取 Canvas 容器標籤, 然後通過 canvas.getContext("2d") 來獲取繪製對象。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<canvas id="canvas"/>
<script>
//getElementById() 來訪問 canvas 元素
const canvas = document.getElementById("canvas");
const context = canvas.getContext("2d");
context.fillStyle= 'rgb(222,155,155)';
context.fillRect(0,0,canvas.width,canvas.height);
</script>
</body>
</html>
複製代碼
2、設置畫布的寬高和背景色
通過 canvas 的 width 和 height 屬性來設置畫布的大小。通過 fillStyle 屬性設置繪製區域的顏色。fillRect 來設置繪製區域大小爲座標點爲左上角固定寬高的距形。
canvas.width 設置畫布的寬
canvas.height 設置畫布的高
context.fillStyle 設置填充顏色
context.fillRect 設置距形
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<canvas id="canvas"/>
<script>
//getElementById() 來訪問 canvas 元素
const canvas = document.getElementById("canvas");
//設置畫布的寬高
canvas.width=400;
canvas.height=200;
//獲取繪製的對象
const context = canvas.getContext("2d");
//設置填充的顏色是顏色漸變等
context.fillStyle= 'rgb(222,155,155)';
//填充區域爲矩形,來定義位置和大小
context.fillRect(0,0,canvas.width,canvas.height);
</script>
</body>
</html>
複製代碼
image.png
設置畫布的寬高爲 width=1000;height=500;
效果如下:
//畫布寬高
canvas.width=1000;
canvas.height=5000;
...
context.fillStyle= 'rgb(222,155,155)';
複製代碼
image.png
3、設置漸變色
漸變色一直是 UI 中極其多見的效果。不僅增添美感, 更是高大上道路上的比不可少的祕訣。接下來咋們來體驗一下前端的漸變。
線性漸變
createLinearGradient(x0: number, y0: number, x1: number, y1: number)
(x0,y0) 鏈接 (x1,y1)方向上進行漸變。如下 (0,0) 到(canvas.width,0)是水平方向上漸變。
addColorStop(offset: number, color: string): void;
用來設置漸變色起始的比例位置。
我們來看看效果
const gradient = context.createLinearGradient(0, 0, canvas.width,0);
gradient.addColorStop(0 ,"rgb(100,200,155)")
gradient.addColorStop(0.5,"rgb(200,120,155)")
gradient.addColorStop(1.0,"rgb(200,220,255)")
context.fillStyle = gradient
複製代碼
image.png
如下 (0,0) 到(canvas.width,canvas.height)是對角線方向上漸變我們來看看效果
image.png
任意方向上線性漸變
const gradient = context.createLinearGradient(0, 0, canvas.width,canvas.height);
複製代碼
image.png
總結漸變色方向的確定通過 (x0,y0) 和(x1,y1)連線方向即可。通過 addColorStop 來進行比例設置漸變色值所起始範圍。
徑向漸變
createRadialGradient(x0: number, y0: number, r0: number, x1: number, y1: number, r1: number)
r0 和 r1 半徑比較那個大,那麼就從那個到半徑小的方向進行漸變, 而不是從裏到外或者從外到裏。
//region 4.徑向漸變色
const canvas = document.getElementById("canvas");
//設置畫布的寬高
canvas.width=1000;
canvas.height=500;
//繪製的對象獲取
const context = canvas.getContext("2d");
//Radial(徑向)
//createRadialGradient(x0: number, y0: number, r0: number, x1: number, y1: number, r1: number)
const gradient = context.createRadialGradient(canvas.width / 2, canvas.height / 2, 50, canvas.width / 2, canvas.height / 2, 20);
gradient.addColorStop(0 ,"rgb(100,200,155)")
gradient.addColorStop(0.8,"rgb(200,120,155)")
gradient.addColorStop(1.0,"rgb(00,120,105)")
context.fillStyle = gradient
//設置填充的區域爲巨形 fillRect(x: number, y: number, w: number, h: number)
context.fillRect(0,0,canvas.width,canvas.height);
//endregion
複製代碼
半徑 50 到 20 漸變 - 由外到內過程如下
image.png
半徑 20 到 50 漸變 - 由內到外過程如下
image.png
漸變就到這裏... 案例中會充分的使用漸變的。由於時間問題單獨漸變案例就不寫了。
三、畫布的變換
畫布通過 translate、rotate、scale、skew 等進行畫布的變換, 可以讓我們繪製過程事半功倍, 猶魚得水。默認情況畫布的座標系是左上角, 我們可以在座標 (0,0) 繪製到 (100,100) 且連線。如下代碼和效果:
context.beginPath() 表示開始一段新的路徑, 下次填充只會修改此段路徑內容
context.moveTo(x, y) 路徑的起始點
context.lineTo(x, y) 鏈接到下一個點
context.strokeStyle = gradient 設置未閉合路徑的顏色
context.stroke() 路徑爲線
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>canvas_change</title>
</head>
<body>
<canvas id="canvas"/>
</body>
<script>
//region 1.變換traslate
const canvas = document.getElementById("canvas");
//設置畫布的寬高
canvas.width = 100;
canvas.height = 100;
//繪製的對象獲取
const context = canvas.getContext("2d");
//此段路徑繪製開始
context.beginPath()
context.moveTo(0, 0)
context.lineTo(100, 100)
//設置線的顏色爲漸變色
const gradient = context.createLinearGradient(
0,
0, canvas.width, canvas.height);
gradient.addColorStop(0, "rgb(100,200,155)")
gradient.addColorStop(0.8, "rgb(200,120,155)")
gradient.addColorStop(1.0, "rgb(00,120,105)")
context.strokeStyle = gradient
//畫不是閉合區域 fill是閉合區域
context.stroke()
</script>
</html>
複製代碼
image.png
畫布 translate[平移]
我們常見的 ECharts 等圖表都可以看到有座標系, 而我們的座標系默認是左上角。大部分常見的座標系都不是在左上角的。如果以左上角爲圓點繪製起來也許比較費勁。最希望的是以 (0,0) 爲我們想要的相對位置,這樣處理很多事變的簡單。
折線圖
上面我們學會了繪製線條。那我們繪製出默認的座標系, 且在默認的圓心左上角繪製一個半徑爲 50 的圓圈。
<script>
//region 1.變換traslate
const canvas = document.getElementById("canvas");
//設置畫布的寬高
canvas.width = 400;
canvas.height = 200;
//繪製的對象獲取
const context = canvas.getContext("2d");
//設置線的顏色爲漸變色
const gradient = context.createLinearGradient(
0,
0, canvas.width, canvas.height);
gradient.addColorStop(0, "rgb(100,200,155)")
gradient.addColorStop(0.8, "rgb(200,120,155)")
gradient.addColorStop(1.0, "rgb(00,120,105)")
context.strokeStyle = gradient
//繪製X軸開始
context.beginPath()
context.moveTo(0, 0)
context.lineTo(canvas.width, 0)
context.closePath()
//畫不是閉合區域 fill是閉合區域
context.stroke()
//繪製Y軸
context.beginPath()
context.moveTo(0, 0)
context.lineTo(0, canvas.height)
context.closePath()
//畫不是閉合區域 fill是閉合區域
context.stroke()
context.beginPath()
//繪製在圓心繪製圓圈
context.arc(0, 0, 50, 0, Math.PI * 2, true);
context.closePath()
//畫不是閉合區域 fill是閉合區域
context.fillStyle = gradient
context.fill()
</script>
image.png
接下來我想將座標遠點放到畫布中間, 繪製之前加平移變換。我們可以看出繪製過程中圓的座標軸是以畫布中心爲圓點繪製座標軸和圓的, 當然你可以任意的平移。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>canvas_change</title>
</head>
<body>
<canvas id="canvas"/>
</body>
<script>
//region 1.變換traslate
const canvas = document.getElementById("canvas");
//設置畫布的寬高
canvas.width = 400;
canvas.height = 200;
//繪製的對象獲取
const context = canvas.getContext("2d");
//設置線的顏色爲漸變色
const gradient = context.createLinearGradient(
0,
0, canvas.width, canvas.height);
gradient.addColorStop(0, "rgb(100,200,155)")
gradient.addColorStop(0.8, "rgb(200,120,155)")
gradient.addColorStop(1.0, "rgb(00,120,105)")
context.fillStyle = "rgba(100,200,155,0.2)"
context.fillRect(0, 0, canvas.width, canvas.height);
context.strokeStyle = gradient
//平移畫布
context.translate(canvas.width/2,canvas.height/2)
//繪製X軸開始
context.beginPath()
context.moveTo(0, 0)
context.lineTo(canvas.width, 0)
context.closePath()
//畫不是閉合區域 fill是閉合區域
context.stroke()
//繪製Y軸
context.beginPath()
context.moveTo(0, 0)
context.lineTo(0, canvas.height)
context.closePath()
//畫不是閉合區域 fill是閉合區域
context.stroke()
context.beginPath()
//繪製在圓心繪製圓圈
context.arc(0, 0, 50, 0, Math.PI * 2, true);
context.closePath()
//畫不是閉合區域 fill是閉合區域
context.fillStyle = "rgb(200,120,155)"
context.fill()
</script>
</html>
image.png
畫布 rotate【旋轉】
首先我們猜想一下畫布的旋轉, 然後去證明是否正確。首先繪製一個線, 然後旋轉畫布 10 度, 再次繪製同樣的線。繪圖前後對比如下:
//旋轉畫布
context.rotate(Math.PI/180*10)
image.png
畫布 scale【縮放】
畫布 Canvas 通過 scale(float sx, float sy) 可以將繪製座標系轉換爲我們希望的座標系。例如默認座標系是如下:
我心目中的座標系並不是這樣的而是這樣那樣的如下:
接下來我們想要得到一個如下 2 一樣的座標系。
沿 x 軸鏡像,就相當於 canvas.scale(1, -1), 沿 y 軸鏡像,就相當於 canvas.scale(-1, 1), 沿原點鏡像,就相當於 canvas.scale(-1, -1)
分析圖二座標系可以看到圓點在左下角。y 軸向上爲正方向, x 軸向右爲正方向, 和默認的座標系左上角對比,只是 y 軸方向相反。這時候我們就可以利用 canvas.scale(1,-1) 鏡像變換, 再通過平移向下即可。
沿 x 軸鏡像 scale(1,-1)
向下平移 canvas.height 即可 --- 這裏注意了因爲座標系被 scale(1,-1) 之後座標系向上爲正, 向下平移需要 - canvas.height 代碼部分
<script>
//region 1.變換rote
const canvas = document.getElementById("canvas");
//設置畫布的寬高
canvas.width = 400;
canvas.height = 200;
//繪製的對象獲取
const context = canvas.getContext("2d");
//設置線的顏色爲漸變色
const gradient = context.createLinearGradient(
0,
0, canvas.width, canvas.height);
gradient.addColorStop(0, "rgb(100,200,155)")
gradient.addColorStop(0.8, "rgb(200,120,155)")
gradient.addColorStop(1.0, "rgb(00,120,105)")
context.fillStyle = "rgba(100,200,155,0.2)"
context.fillRect(0, 0, canvas.width, canvas.height);
context.strokeStyle = gradient
//沿x軸鏡像變換必須明白最重要的一點,這時候y座標系向下爲正,經過下面scale(1,-1)y軸座標系鄉下爲負。
context.scale(1,-1)
//向下平移,注意這時候鄉下是負方向哦
context.translate(0,-canvas.height)
//繪製X軸開始
context.beginPath()
context.moveTo(0, 0)
context.lineTo(canvas.width, 0)
context.closePath()
//畫不是閉合區域 fill是閉合區域
context.stroke()
//繪製Y軸
context.beginPath()
context.moveTo(0, 0)
context.lineTo(0, canvas.height)
context.closePath()
//畫不是閉合區域 fill是閉合區域
context.stroke()
context.beginPath()
//繪製線
context.moveTo(0,0);
context.lineTo(100,100);
context.closePath()
//畫不是閉合區域 fill是閉合區域
context.strokeStyle = "rgb(200,120,155)"
context.stroke()
</script>
image.png
好了, 到這裏我們學會了座標系的變換, 我相信大家應該覺得這麼簡單的東西, 就這樣麼?當然了座標變換有着極大的便利性和簡化功能,我們逐步深入,畫布的變換定會讓你事半功倍, 遊刃有餘。
四、手寫 ECharts 案例
1、折線圖
如下是 ECharts 官方第一個案例: 都是文字和各種線圓組成, 但是其中有很多跟我們的座標變換有着千絲萬縷的關係,咋們來一步步分析畫布變換的重要性。
分析繪製過程
1. 變換座標系 -- 爲操作帶來方便
2. 繪製平行 X 軸的線條
3. 繪製文字
4. 繪製折線和圓
1. 變換座標系 -- 爲操作帶來方便
我們分析上圖, 基本是左下角爲座標圓心進行整個折線圖的繪製。但我們座標系默認是左上角, 所以接下來變換座標系圓點到左下角即可, 上面案例中我們看到距離底部和座標有一定的距離用來繪製文字我們設置距離下面 50 左邊 40。
//region 1.變換rote
const marginBootom = 50;
const marginLeft = 40;
const canvas = document.getElementById("canvas");
//設置畫布的寬高
canvas.width = 400;
canvas.height = 200;
//繪製的對象獲取
const context = canvas.getContext("2d");
//漸變
context.strokeStyle = "rgb(0,0,0,1)"
context.lineWidth=0.2
//沿x軸鏡像對稱變換畫布
context.scale(1, -1)
//向下平移畫布-marginBootom的高度
context.translate(marginLeft, -canvas.height+marginBootom)
此時的座標系是這樣的,爲了演示和觀察方便我這裏貼一下座標系。接下來我們開始後面的繪製過程。
image.png
2. 繪製平行 X 軸的線條
這個平行 X 軸的線條我們難道需要計算每條線的起點和終點麼?這麼麻煩?當然來畫布的變換很好的解決了這個問題。我們的畫布是有狀態的每次的狀態都可以進行保存也可以返回之前的狀態。如下: 我們繪製了最底下的一條線。
image.png
那我們可以每次變換座標系向 Y 軸方向向上平移固定高度再繪製這條線線。多次繪製就形成了平行 X 軸的多條線段。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Line</title>
</head>
<body>
<canvas id="canvas"/>
<script>
//region 1.變換rote
const marginBootom = 50;
const marginLeft = 40;
const canvas = document.getElementById("canvas");
//設置畫布的寬高
canvas.width = 400;
canvas.height = 300;
//繪製的對象獲取
const context = canvas.getContext("2d");
//漸變
context.strokeStyle = "rgb(0,0,0,1)"
context.lineWidth=0.08
//沿x軸鏡像對稱變換畫布
context.scale(1, -1)
//向下平移畫布-marginBootom的高度
context.translate(marginLeft, -canvas.height+marginBootom)
//保存好當前畫布的狀態。因爲我們的圓心在左下角,我們還需要返回到這個遠點進行其他操作。
context.save()
const heightOfOne=30
//繪製X軸開始
for(let i=0; i<7; i++){
context.beginPath()
context.moveTo(0, 0)
context.lineTo(canvas.width, 0)
context.closePath()
//畫不是閉合區域 fill是閉合區域
context.stroke()
//每次繪製完之後繼續往上平移
context.translate(0,heightOfOne)
}
context.restore()
</script>
</body>
</html>
image.png
3. 繪製刻度
同樣的方法我們將 X 軸分爲 7 等分,沒以等分我們都要繪製一個刻度。代碼如下
image.png
//繪製刻度開始
context.save()
context.lineWidth=0.2
for(let i=0; i<8; i++){
context.beginPath()
context.moveTo(0, 0)
context.lineTo(0, -5)
context.closePath()
//畫不是閉合區域 fill是閉合區域
context.stroke()
//每次繪製完之後繼續往上平移
context.translate(widthOfOn,0)
}
context.restore()
image.png
繪製 X 軸下面文字
如果非常精確, 這裏可能涉及到文字的測量。當然了我們需要精確, 由於時間問題我這裏也就不細細說明文字繪製測量的詳細 API。代碼中有解釋。
context.fillText("文字",x,y); 表示在(x,y)位置繪製文字
image.png
//x軸繪製文字數組
const xText = new Array("Mon", "Tue", "Wed","Thu","Fir","Sat","Sun");
for(let i=0; i<xText.length; i++){
//畫不是閉合區域 fill是閉合區域
context.stroke()
//每次繪製完之後繼續往上平移
if(i===0){
//分析之後第一次移動了單位長度的一半。後面的每次都平移一個刻度長度,座標圓心就平移到了每個刻度的中間。y軸向下平移了5個像素。這樣就和X軸不會重合。
context.translate(widthOfOn/2,-5)
}else{
context.translate(widthOfOn,0)
}
context.fillText(xText[i],0,0);
}
context.restore()
image.png
到這裏我們發現字體是沿着 X 軸鏡像變換的。因爲默認右下方是正方向,我們經過變換右上方爲正方向。所以這裏繪製之前我們需要將座標系還原即可。
//x軸繪製文字數組
context.save()
const xText = new Array("Mon", "Tue", "Wed","Thu","Fir","Sat","Sun");
//這裏沿着X軸鏡像對稱變換。那麼Y軸向下爲正,X沒變向右爲正。
context.scale(1,-1)
for(let i=0; i<xText.length; i++){
//畫不是閉合區域 fill是閉合區域
context.stroke()
//每次繪製完之後繼續往上平移
if(i===0){
//分析之後第一次移動了單位長度的一半。後面的每次都平移一個刻度長度,座標圓心就平移到了每個刻度的中間。y軸向下平移了5個像素。這樣就和X軸不會重合。
context.translate(widthOfOn/2,15)
}else{
context.translate(widthOfOn,0)
}
context.fillText(xText[i],0,0);
}
//還原到遠點爲左下角狀態。
context.restore()
image.png
繪製 Y 軸左邊的文字
還需要我 BB 麼....
image.png
//保存左下角爲座標圓點狀態。
context.save()
context.scale(1,-1)
context.translate(-20,0)
context.font = "7pt Calibri";
//Y軸左邊繪製文字
for(let i=0; i<7; i++){
//畫不是閉合區域 fill是閉合區域
context.stroke()
//每次繪製完之後繼續往上平移
context.fillText((50*i).toString(),0,0);
context.translate(0,-heightOfOne)
}
context.restore()
image.png
字體看上圖都繪製完成, 但是對比原圖我們的文字並不在每個刻度的正中間。如上圖藍色指向。如下圖上面是我們繪製的。下面是案例的對比之下我們繪製的文字中間位置不是單位刻度中間位置。我們只需要計算出文字的寬度即可。然後繪製的文字 X 減去位子寬度的一半即可。好好想想很簡單的把?
context.measureText("文本"); 測量文字
context.fillText(xText[i],-textWidth.width/2,0);
//x軸繪製文字數組
context.save()
const xText = new Array("Mon", "Tue", "Wed","Thu","Fir","Sat","Sun");
//這裏沿着X軸鏡像對稱變換。那麼Y軸向下爲正,X沒變向右爲正。
context.scale(1,-1)
context.font = "7pt Calibri";
for(let i=0; i<xText.length; i++){
//畫不是閉合區域 fill是閉合區域
context.stroke()
//每次繪製完之後繼續往上平移
if(i===0){
//分析之後第一次移動了單位長度的一半。後面的每次都平移一個刻度長度,座標圓心就平移到了每個刻度的中間。y軸向下平移了5個像素。這樣就和X軸不會重合。
context.translate(widthOfOn/2,15)
}else{
context.translate(widthOfOn,0)
}
const textWidth = context.measureText(xText[i]);
context.fillText(xText[i],-textWidth.width/2,0);
}
//還原到遠點爲左下角狀態。
context.restore()
image.png
4. 繪製折線和圓
所有的繪製有個小問題, 不管那一端需要將我們實際的數據映射到我們的座標系中。這個就簡單的計算而已,這裏提一下, 例如我們的座標系內部是按照畫布的寬度來計算的,也就是像素, 當時我們實際的數據可能是任意的數據。所以我們需要按照數據來進行映射到我們的座標系。案例中的折線數據就是如此:
定義類進行座標的儲存,第一次使用前端的對象怪怪的.....
const Point = {
createNew: function (x,y) {
const point = {};
point.x = x;
point.y = y;
return point;
}
};
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Line</title>
<script type="text/javascript" src="js/canvas..js"></script>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
//region 1.變換rote
const marginBootom = 50;
const marginLeft = 40;
const canvas = document.getElementById("canvas");
//設置畫布的寬高
canvas.width = 500;
canvas.height = 300;
//繪製的對象獲取
const context = canvas.getContext("2d");
//漸變
context.strokeStyle = "rgb(0,0,0,1)"
context.lineWidth = 0.09
//沿x軸鏡像對稱變換畫布
context.scale(1, -1)
//向下平移畫布-marginBootom的高度
context.translate(marginLeft, -canvas.height + marginBootom)
//繪製X軸和刻度
drawX(context)
//繪製文字
drawText(context)
//繪製折線和圓
drawLine(context)
//繪製圓
drawCircle(context)
</script>
</body>
</html>
canvas.js 文件
//繪製折線
function drawLine(context) {
//繪製折線段
const widthOfOn = (canvas.width - marginLeft) / 7
const danweiHeight=35/50;//每個數字佔用的實際像素高度
const point01 = Point.createNew(widthOfOn/2,150*danweiHeight);
const point02 = Point.createNew(widthOfOn/2+widthOfOn,250*danweiHeight);
const point03 = Point.createNew(widthOfOn/2+widthOfOn*2,225*danweiHeight);
const point04 = Point.createNew(widthOfOn/2+widthOfOn*3,211*danweiHeight);
const point05 = Point.createNew(widthOfOn/2+widthOfOn*4,140*danweiHeight);
const point06 = Point.createNew(widthOfOn/2+widthOfOn*5,148*danweiHeight);
const point07 = Point.createNew(widthOfOn/2+widthOfOn*6,260*danweiHeight);
const points = [point01, point02, point03, point04, point05, point06, point07];
context.save();
context.beginPath();
for (let index = 0; index < points.length; index++) {
context.lineTo(points[index].x,points[index].y);
}
context.strokeStyle="rgb(93,111,194)"
context.lineWidth=1
context.shadowBlur = 5;
context.stroke();
context.closePath();
context.restore();
}
//繪製圓圈
function drawCircle(context) {
const widthOfOn = (canvas.width - marginLeft) / 7
const danweiHeight=35/50;//每個數字佔用的實際像素高度
const point01 = Point.createNew(widthOfOn/2,150*danweiHeight);
const point02 = Point.createNew(widthOfOn/2+widthOfOn,250*danweiHeight);
const point03 = Point.createNew(widthOfOn/2+widthOfOn*2,225*danweiHeight);
const point04 = Point.createNew(widthOfOn/2+widthOfOn*3,211*danweiHeight);
const point05 = Point.createNew(widthOfOn/2+widthOfOn*4,140*danweiHeight);
const point06 = Point.createNew(widthOfOn/2+widthOfOn*5,148*danweiHeight);
const point07 = Point.createNew(widthOfOn/2+widthOfOn*6,260*danweiHeight);
const points = [point01, point02, point03, point04, point05, point06, point07];
context.save();
context.beginPath();
for (let index = 0; index < points.length; index++) {
context.beginPath();
context.arc(points[index].x,points[index].y,3, 0, Math.PI * 2, true);
context.closePath();
context.fillStyle = 'rgb(100,255,255)';
context.shadowBlur = 5;
context.shadowColor = 'rgb(100,255,255)';
context.fill()
}
canvas.restore();
}
image.png
到這裏, 如果你一步步走下來對於不明白的百度搜索看 API 我相信只要你自己練習至少三個每個使用一下畫布的變換, 那麼自定義你就已經達到不錯的水平, 當然對於各種騷操作, 我們可以進一步學習貝塞爾曲線等和動畫加手勢等。
平滑的折線圖
今天第一次接觸 HTML5 的自定義, 其實各端的自定義都是底層渲染繪製基礎上的 API 封裝, 一個好的平臺或者語言都會有完善的 API,H5 再我看來之所以有 ECharts 這樣的庫可以所很完善了 API, 所以這個章節我很有信心。
曲線開發中時常出現在自定義圖標裏面, 學會曲線繪製能讓你的軟件更具創造性和無窮的魅力。
一、曲線認識與理解
- 由於之前 Android 寫過一些概論和理解, 所以這裏就貼一下 android 的代碼和理解,時間問題就這裏可以看基本的理解即可
曲線常見的API
1.一階曲線
2.二階曲線
3.三階曲線
我們在初中高中學習中學習了各種直線,圓,橢圓,正玄...曲線等對應的座標系方程吧, 接下來我們回顧一下我們的直線和曲線等方程。
- 第一步我們還是定義一個類新建座標系, 屏幕可旋轉橫屏顯示
package com.example.android_draw.view
import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.view.View
/**
*
* ┌─────────────────────────────────────────────────────────────┐
* │┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐│
* ││Esc│!1 │@2 │#3 │$4 │%5 │^6 │&7 │*8 │(9 │)0 │_- │+= │|\ │`~ ││
* │├───┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴───┤│
* ││ Tab │ Q │ W │ E │ R │ T │ Y │ U │ I │ O │ P │{[ │}] │ BS ││
* │├─────┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴─────┤│
* ││ Ctrl │ A │ S │ D │ F │ G │ H │ J │ K │ L │: ;│" '│ Enter ││
* │├──────┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴────┬───┤│
* ││ Shift │ Z │ X │ C │ V │ B │ N │ M │< ,│> .│? /│Shift │Fn ││
* │└─────┬──┴┬──┴──┬┴───┴───┴───┴───┴───┴──┬┴───┴┬──┴┬─────┴───┘│
* │ │Fn │ Alt │ Space │ Alt │Win│ HHKB │
* │ └───┴─────┴───────────────────────┴─────┴───┘ │
* └─────────────────────────────────────────────────────────────┘
* 版權:渤海新能 版權所有
*
* @author feiWang
* 版本:1.5
* 創建日期:2/8/21
* 描述:Android_Draw
* E-mail : 1276998208@qq.com
* CSDN:https://blog.csdn.net/m0_37667770/article
* GitHub:https://github.com/luhenchang
*/
class LHC_Cubic_View @JvmOverloads constructor(
context: Context?,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
init {
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
}
}
爲了方便觀察和繪製進行了網格和座標軸的繪製。我相信學過上一篇文章的對於畫布的變換操作已經熟練掌握了, 網格座標軸的代碼我就不再講解, 看圖。
1. 方程式映射到座標系
記得我們初中學過Y(x)=ax+b的直線方程吧。我們來看看這個方程映射到座標系中的圖像。首先定義一個函數 y=2x-80獲取一段點集合, 爲了看效果我們 x 偶數時候繪製, 然後繪製點即可。代碼如下:
private var number=0..420
//直線方程y=2x-80
private fun drawzxLine(canvas: Canvas) {
pointList= ArrayList()
//繪製方程式y=10x+20
val gPaint = getPaint()
number.forEach { t ->
val point=PointF()
if (t%2==0) {//x軸偶數點進行繪製
point.x = t.toFloat()
point.y = 2f * t - 80
pointList.add(point)
canvas.drawPoint(point.x, point.y, gPaint)
}
}
}
複製代碼
- 同樣圓的方程, 橢圓的方程等都可以這樣進行映射到座標系。
2.所表示的曲線是以 O(a,b) 爲圓心,以 r 爲半徑的圓。 - 例如:(x-10)2+(y-10)2=1602 我們進行映射到座標系內。
- 解方程應該沒問題吧。我們來變換爲我們熟悉的方程式:
(x-10)2+(y-10)2=1602
移項 (y-10)2=1602-(x-10)2
開方 轉換爲函數 Math 方程式如下:
開方之後有正負值需要注意y=sqrt(160.0.pow(2.0).toFloat() - ((point.x - 10).toDouble()).pow(2.0)).toFloat() + 10
y=-sqrt(160.0.pow(2.0).toFloat() - ((pointDown.x - 10).toDouble()).pow(2.0)).toFloat() + 10
//繪製圓圈
number.forEach { t ->
val point = PointF()
val pointDown = PointF()
//(x-10)2+(y-10)2=1602
point.x = t.toFloat()
pointDown.x = t.toFloat()
//y計算應該不用我說吧。
point.y =
sqrt(160.0.pow(2.0).toFloat() - ((point.x - 10).toDouble()).pow(2.0)).toFloat() + 10
pointDown.y = -sqrt(
160.0.pow(2.0).toFloat() - ((pointDown.x - 10).toDouble()).pow(2.0)
).toFloat() + 10
canvas.drawPoint(point.x, point.y, gPaint)
canvas.drawPoint(pointDown.x, pointDown.y, gPaint)
}
複製代碼
2. 貝塞爾曲線
通過上面我們發現凡是函數都可以和座標系繪製進行一一映射, 當然了貝塞爾曲線也是有方程式的。有如下:
線性貝塞爾曲線
- 給定點 P0、P1,線性貝塞爾曲線只是一條兩點之間的直線。這條線由下式給出:
二次方貝塞爾曲線
- 二次方貝塞爾曲線的路徑由給定點 P0、P1、P2 的函數 B(t)追蹤:
三次方貝塞爾曲線
P0、P1、P2、P3 四個點在平面或在三維空間中定義了三次方貝塞爾曲線。曲線起始於 P0 走向 P1,並從 P2 的方向來到 P3。一般不會經過 P1 或 P2;公式如下:
當然在 Android 端的 Native 層已經封裝好了方法,二次方貝塞爾曲線和三次方貝塞爾曲線, 已知函數當然可以進行封裝。
在Android端提供了二階和三階
`二次方貝塞爾曲線`:
public void quadTo(float x1, float y1, float x2, float y2)
public void rQuadTo(float dx1, float dy1, float dx2, float dy2)
`三次方貝塞爾曲線`:
public void cubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
public void rCubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
複製代碼
接下來我們繪製一個二階曲線,控制點可以隨着手勢的移動和下按進行對應的屏幕移動, 對於手勢座標系和屏幕座標系的映射轉換上節折線裏面說很明白了, 這裏不多做解釋。
- quadTo(float x1, float y1, float x2, float y2)
//記錄移動的canvas畫布座標,不是手勢座標,由手勢座標轉換爲canvas座標進行刷新
private var moveX: Float = 160f
private var moveY: Float = 160f
private fun drawQuz(canvas: Canvas) {
controllRect = Rect(
(moveX - 30f).toInt(),
(moveY + 30f).toInt(),
(moveX + 30).toInt(),
(moveY - 30f).toInt()
)
val quePath = Path()
canvas.drawCircle(0f, 0f, 10f, getPaintCir(Paint.Style.FILL))
canvas.drawCircle(320f, 0f, 10f, getPaintCir(Paint.Style.FILL))
//第一個點和控制點的連線到最後一個點鏈線。爲了方便觀察
val lineLeft = Path()
lineLeft.moveTo(0f, 0f)
lineLeft.lineTo(moveX, moveY)
lineLeft.lineTo(320f, 0f)
canvas.drawPath(lineLeft, getPaint(Paint.Style.STROKE))
//第一個p0處畫一個圓。第二個p1處畫一個控制點圓,最後畫一個。
canvas.drawCircle(moveX, moveY, 10f, getPaintCir(Paint.Style.FILL))
quePath.quadTo(moveX, moveY, 320f, 0f)
canvas.drawPath(quePath, getPaint(Paint.Style.STROKE))
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
ACTION_DOWN,
ACTION_MOVE -> {
//在控制點附近範圍內部,進行移動
Log.e("x=", "onTouchEvent: (x,y)"+(event.x - width / 2).toInt()+":"+(-(event.y - height / 2)).toInt())
//將手勢座標轉換爲屏幕座標
moveX = event.x - width / 2
moveY = -(event.y - height / 2)
invalidate()
}
}
return true
}
複製代碼
上圖可以拖動控制點, 在起點和結尾之間的曲線隨着控制點發生了變形。控制點靠近那一側弧度的凸起就偏向那一側, 初步的認識這一個規律即可, 而練習中不斷的去調節控制點達到我們的需求。但是在上圖中我們回發現弧度不夠圓圈, 在三階函數里面可以很好的調節弧度。接下來我們來看看三階函數
三階曲線
- public void cubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
同樣我們在座標系內繪製三階曲線。爲了很好的看到效果我們這次進行來精細的控制, 我們可以拖動任意我們想要拖動的控制點進行觀察我們的三階曲線。在上章節折線中對於手勢配合Rect的contains方法可以進行局部的點擊, 當然了拖動也是沒問題的。如下圖: 我們只需要在控制點附近進行繪製距形包裹住控制點, 手勢滑動時時刷新控制點和對應的距形即可。
到這裏我想我們應該大概的明白二階和三階曲線對於弧度的大致方向控制了吧。你以爲這樣就結束了麼。接下來下來開始正式的進入曲線應用。
image.png
曲線圖分析
- 三階曲線的拯救
當 y1y_1y1<y2y_2y2 如上圖 1. 求出中點座標 x 軸下部分控制點 x+40px, 上部分 x-40px,y 軸也可以調整來搞搞平滑度下部分控制點 y-40x, 上部分 y+40。
- 獲取中點的座標(X 中 X_中 X 中、Y 中 Y_中 Y 中)= ((x1x_1x1+x2x_2x2)/2、(y1y_1y1+y2y_2y2)/2)
2.x1x_1x1 到 X 中 X_中 X 中之間的座標 =((x1x_1x1+x 中 x_中 x 中)/2、(y1y_1y1+y 中 y_中 y 中)/2)
3.x 中 x_中 x 中到 X2X_2X2 之間的座標 =((x 中 x_中 x 中 + x2x_2x2)/2、(y 中 y_中 y 中 + y2y_2y2)/2)當 y1y_1y1>y2y_2y2 如上圖 2. 求出中點座標 x 軸上部分 + 40px, 下部分 x-40px,y 軸也可以調整,y 軸也可以調整來搞搞平滑度上部分控制點 y+40x, 下部分 y-40。
- 獲取中點的座標(X 中 X_中 X 中、Y 中 Y_中 Y 中)= ((x1x_1x1+x2x_2x2)/2、(y1y_1y1+y2y_2y2)/2)
2.x1x_1x1 到 X 中 X_中 X 中之間的座標 =((x1x_1x1+x 中 x_中 x 中)/2、(y1y_1y1+y 中 y_中 y 中)/2)
3.x 中 x_中 x 中到 X2X_2X2 之間的座標 =((x 中 x_中 x 中 + x2x_2x2)/2、(y 中 y_中 y 中 + y2y_2y2)/2)
/**
* 繪製曲線
* @param context
*/
function drawQuaraticLine(context) {
//繪製折線段
const widthOfOn = (canvas.width - marginLeft) / 7
const danweiHeight=35/50;//每個數字佔用的實際像素高度
const point01 = Point.createNew(widthOfOn/2,150*danweiHeight);
const point02 = Point.createNew(widthOfOn/2+widthOfOn,250*danweiHeight);
const point03 = Point.createNew(widthOfOn/2+widthOfOn*2,225*danweiHeight);
const point04 = Point.createNew(widthOfOn/2+widthOfOn*3,211*danweiHeight);
const point05 = Point.createNew(widthOfOn/2+widthOfOn*4,140*danweiHeight);
const point06 = Point.createNew(widthOfOn/2+widthOfOn*5,148*danweiHeight);
const point07 = Point.createNew(widthOfOn/2+widthOfOn*6,260*danweiHeight);
const dataList = [point01, point02, point03, point04, point05, point06, point07];
context.save();
context.beginPath();
context.lineTo(point01.x,point01.y)
//500=grid_width-40 每個單位的長度的=像素長度
const danweiX = widthOfOn;
const grid_width=widthOfOn;
const xMoveDistance=20
const yMoveDistance=30
for (let index = 0;index < dataList.length-1;index++) {
if (dataList[index] === dataList[index + 1]) {
context.lineTo(danweiX*(index+1),0)
} else if(dataList[index] < dataList[index + 1]){//y1<y2情況
const centerX=(grid_width * index + grid_width * (1 + index)) / 2
const centerY=(dataList[index].y + dataList[index + 1].y) / 2
const controX0=(grid_width * index+centerX)/2
const controY0=(dataList[index].y+centerY)/2
const controX1=(centerX+ grid_width * (1 + index))/2
const controY1=(centerY+dataList[index+1].y)/2
context.bezierCurveTo(controX0+xMoveDistance,controY0-yMoveDistance,controX1-xMoveDistance,controY1+yMoveDistance,grid_width * (1 + index),dataList[index + 1].y)
}else{
const centerX=(grid_width * index + grid_width * (1 + index)) / 2
const centerY=(dataList[index].y + dataList[index + 1].y) / 2
const controX0=(grid_width * index+centerX)/2
const controY0=(dataList[index].y+centerY)/2
const controX1=(centerX+ grid_width * (1 + index))/2
const controY1=(centerY+dataList[index+1].y)/2
context.bezierCurveTo(controX0+xMoveDistance,controY0+yMoveDistance,controX1-xMoveDistance,controY1-yMoveDistance,grid_width * (1 + index),dataList[index + 1].y)
}
}
context.strokeStyle="rgb(93,111,194)"
context.lineWidth=2
context.shadowBlur = 5;
context.stroke();
context.closePath();
context.restore();
}
複製代碼
由於時間問題對於參數沒有進行詳細的調整, 當然了 X 軸之間的間隙太小,所以看着比較尷尬。如果你有時間自己可以參看我之前的 android 自定義曲線博客來一波
3、填充的折線圖
我們之前搞定了折線和曲線, 但下面這種填充如何搞定?如何進行更騷的操作?我們接下來進行探究。
image.png
我們在之前的基礎上進行,我們可以在之前的基礎上進行繪製一個封閉的多邊形進行填充即可。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Line</title>
<script type="text/javascript" src="js/canvas..js"></script>
<script type="text/javascript" src="js/canvas_fill.js"></script>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
//region 1.變換rote
const marginBootom = 50;
const marginLeft = 40;
const canvas = document.getElementById("canvas");
//設置畫布的寬高
canvas.width = 500;
canvas.height = 300;
//繪製的對象獲取
const context = canvas.getContext("2d");
//漸變
context.strokeStyle = "rgb(0,0,0,1)"
context.lineWidth = 0.09
//沿x軸鏡像對稱變換畫布
context.scale(1, -1)
//向下平移畫布-marginBootom的高度
context.translate(marginLeft, -canvas.height + marginBootom)
//繪製X軸和刻度
drawX(context)
//繪製文字
drawText(context)
//繪製折線和圓
drawFillLine(context)
//繪製圓
drawCircle(context)
</script>
</body>
</html>
複製代碼
填充部分代碼
function drawFillLine(context) {
//繪製折線段
const widthOfOn = (canvas.width - marginLeft) / 7
const danweiHeight=35/50;//每個數字佔用的實際像素高度
const point00 = Point.createNew(0,150*danweiHeight);
const point01 = Point.createNew(widthOfOn/2,150*danweiHeight);
const point02 = Point.createNew(widthOfOn/2+widthOfOn,250*danweiHeight);
const point03 = Point.createNew(widthOfOn/2+widthOfOn*2,225*danweiHeight);
const point04 = Point.createNew(widthOfOn/2+widthOfOn*3,211*danweiHeight);
const point05 = Point.createNew(widthOfOn/2+widthOfOn*4,140*danweiHeight);
const point06 = Point.createNew(widthOfOn/2+widthOfOn*5,148*danweiHeight);
const point07 = Point.createNew(widthOfOn/2+widthOfOn*6,260*danweiHeight);
const points = [point00,point01, point02, point03, point04, point05, point06, point07];
context.save();
context.beginPath();
for (let index = 0; index < points.length; index++) {
context.lineTo(points[index].x,points[index].y);
}
context.strokeStyle="rgb(93,111,194)"
context.lineWidth=1
context.shadowBlur = 5;
context.stroke();
context.closePath();
context.beginPath();
//繪製閉環多邊形
context.moveTo(0,0)
for (let index = 0; index < points.length; index++) {
context.lineTo(points[index].x,points[index].y);
}
context.lineTo(points[points.length-1].x,0);
context.lineTo(0,0);
context.closePath();
context.fillStyle="rgba(93,111,194,0.5)"
context.lineWidth=3
context.shadowBlur = 5;
context.fill();
context.restore();
}
複製代碼
漸變色的使用讓其更具有特點和魅力。當然了我不是一個標準的設計師, 美不在於我們, 在於設計,一個好的設計應該不會像我一樣配色這麼醜吧?
image.png
image.png
image.png
3、多條折線圖
如下多條折線圖, 我們搞定了一條還搞不定多條麼?只需要同樣的操作,只是數據集合不一樣罷了。
image.png
由於時間問題,我們直接在之前的基礎上進行操作
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Line</title>
<script type="text/javascript" src="js/canvas..js"></script>
<script type="text/javascript" src="js/canvas_fill.js"></script>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
//region 1.變換rote
const marginBootom = 50;
const marginLeft = 40;
const canvas = document.getElementById("canvas");
//設置畫布的寬高
canvas.width = 500;
canvas.height = 300;
//繪製的對象獲取
const context = canvas.getContext("2d");
//漸變
context.strokeStyle = "rgb(0,0,0,1)"
context.lineWidth = 0.09
//沿x軸鏡像對稱變換畫布
context.scale(1, -1)
//向下平移畫布-marginBootom的高度
context.translate(marginLeft, -canvas.height + marginBootom)
//繪製X軸和刻度
drawX(context)
//繪製文字
drawText(context)
function drawMoreLine(context) {
//繪製折線段
const widthOfOn = (canvas.width - marginLeft) / 7
const danweiHeight=35/50;//每個數字佔用的實際像素高度
const point01 = Point.createNew(widthOfOn/2, 200*danweiHeight);
const point02 = Point.createNew(widthOfOn/2+widthOfOn, 250*danweiHeight);
const point03 = Point.createNew(widthOfOn/2+widthOfOn*2,225*danweiHeight);
const point04 = Point.createNew(widthOfOn/2+widthOfOn*3,211*danweiHeight);
const point05 = Point.createNew(widthOfOn/2+widthOfOn*4,140*danweiHeight);
const point06 = Point.createNew(widthOfOn/2+widthOfOn*5,148*danweiHeight);
const point07 = Point.createNew(widthOfOn/2+widthOfOn*6,260*danweiHeight);
const point011 = Point.createNew(widthOfOn/2, 150*danweiHeight);
const point012 = Point.createNew(widthOfOn/2+widthOfOn, 200*danweiHeight);
const point013 = Point.createNew(widthOfOn/2+widthOfOn*2,125*danweiHeight);
const point014 = Point.createNew(widthOfOn/2+widthOfOn*3,181*danweiHeight);
const point015 = Point.createNew(widthOfOn/2+widthOfOn*4,90*danweiHeight);
const point016 = Point.createNew(widthOfOn/2+widthOfOn*5,98*danweiHeight);
const point017 = Point.createNew(widthOfOn/2+widthOfOn*6,210*danweiHeight);
const point021 = Point.createNew(widthOfOn/2, 60*danweiHeight);
const point022 = Point.createNew(widthOfOn/2+widthOfOn, 65*danweiHeight);
const point023 = Point.createNew(widthOfOn/2+widthOfOn*2,61*danweiHeight);
const point024 = Point.createNew(widthOfOn/2+widthOfOn*3,70*danweiHeight);
const point025 = Point.createNew(widthOfOn/2+widthOfOn*4,78*danweiHeight);
const point026 = Point.createNew(widthOfOn/2+widthOfOn*5,68*danweiHeight);
const point027 = Point.createNew(widthOfOn/2+widthOfOn*6,72*danweiHeight);
const points = [point01, point02, point03, point04, point05, point06, point07];
const point1s = [point011, point012, point013, point014, point015, point016, point017];
const point2s = [point021, point022, point023, point024, point025, point026, point027];
context.save();
context.beginPath();
for (let index = 0; index < points.length; index++) {
context.lineTo(points[index].x,points[index].y);
}
context.strokeStyle="rgb(93,111,194)"
context.lineWidth=1
context.shadowBlur = 5;
context.stroke();
context.closePath();
context.beginPath();
for (let index = 0; index < point1s.length; index++) {
context.lineTo(point1s[index].x,point1s[index].y);
}
context.strokeStyle="rgb(193,111,194)"
context.lineWidth=1
context.shadowBlur = 5;
context.stroke();
context.closePath();
context.beginPath();
for (let index = 0; index < point2s.length; index++) {
context.lineTo(point2s[index].x,point2s[index].y);
}
context.strokeStyle="rgb(293,111,294)"
context.lineWidth=1
context.shadowBlur = 5;
context.stroke();
context.closePath();
context.beginPath();
for (let index = 0; index < point2s.length; index++) {
context.lineTo(point2s[index].x,point2s[index].y-15);
}
context.strokeStyle="rgb(293,211,194)"
context.lineWidth=1
context.shadowBlur = 5;
context.stroke();
context.closePath();
context.beginPath();
for (let index = 0; index < point2s.length; index++) {
context.lineTo(point2s[index].x,point2s[index].y-35);
}
context.strokeStyle="rgb(93,211,294)"
context.lineWidth=1
context.shadowBlur = 5;
context.stroke();
context.closePath();
for (let index = 0; index < points.length; index++) {
context.beginPath();
context.arc(points[index].x,points[index].y, 3, 0, Math.PI * 2, true);
context.closePath();
context.fillStyle = 'rgb(100,255,255)';
context.shadowBlur = 10;
context.shadowColor = 'rgb(100,255,255)';
context.fill()
}
//第一個上面的圓
for (let index = 0; index < points.length; index++) {
context.beginPath();
context.arc(points[index].x,points[index].y, 3, 0, Math.PI * 2, true);
context.closePath();
context.fillStyle = "rgb(93,111,194)";
context.shadowBlur = 10;
context.shadowColor = "rgb(93,111,194)";
context.fill()
}
//第二條線上面的圓
for (let index = 0; index < point1s.length; index++) {
context.beginPath();
context.arc(point1s[index].x,point1s[index].y, 3, 0, Math.PI * 2, true);
context.closePath();
context.fillStyle = "rgb(193,111,194)"
context.shadowBlur = 10;
context.shadowColor ="rgb(193,111,194)"
context.fill()
}
//第三條線的圓
for (let index = 0; index < point2s.length; index++) {
context.beginPath();
context.arc(point2s[index].x,point2s[index].y, 3, 0, Math.PI * 2, true);
context.closePath();
context.fillStyle = "rgb(293,111,294)"
context.shadowBlur = 10;
context.shadowColor ="rgb(293,111,294)"
context.fill()
}
//四...圓
for (let index = 0; index < point2s.length; index++) {
context.beginPath();
context.arc(point2s[index].x,point2s[index].y-15, 3, 0, Math.PI * 2, true);
context.closePath();
context.fillStyle = "rgb(293,211,194)"
context.shadowBlur = 1;
context.shadowColor ="rgb(293,211,194)"
context.fill()
}
//第五條線上面的圓圈
for (let index = 0; index < point2s.length; index++) {
context.beginPath();
context.arc(point2s[index].x,point2s[index].y-35, 3, 0, Math.PI * 2, true);
context.closePath();
context.fillStyle = "rgb(93,211,294)"
context.shadowBlur = 1;
context.shadowColor ="rgb(93,211,294)"
context.fill()
}
context.restore();
}
//繪製填充折線和圓
drawMoreLine(context)
</script>
</body>
</html>
複製代碼
image.png
4、多條折線填充圖
由於時間問題這個就最後一個案例吧。後面的更好的特效案例請期待我的小冊, 一直在進步寫作的路上,希望儘快和大家見面。
分析
閉合區域的疊加而已
image.png
//第一條線閉合區域
context.beginPath();
context.moveTo(0,0)
for (let index = 0; index < points.length; index++) {
context.lineTo(points[index].x,points[index].y);
}
context.lineTo(points[points.length-1].x,0);
context.closePath();
context.fillStyle="rgba(93,111,194,0.5)"
context.lineWidth=11
context.shadowBlur = 5;
context.fill();
context.closePath();
複製代碼
image.png
差幾個文字
//第一條線閉合區域
context.beginPath();
context.moveTo(0,0)
for (let index = 0; index < points.length; index++) {
context.lineTo(points[index].x,points[index].y);
//這裏由於文字反轉所以需要變換座標系。且爲了方便操作每次都將座標圓點移動到頂點跟家方便的操作
context.save()
context.translate(points[index].x,points[index].y)
context.scale(1,-1)
context.fillText(points[index].y+"",0,-10)
//記得文字繪製完成還原座標系,因爲後面還要繪製線,不影響座標系圓點是左下叫爲圓形即可。
context.restore()
}
context.lineTo(points[points.length-1].x,0);
context.closePath();
複製代碼
image.png
5、柱狀圖案例
羣裏有提到寫柱狀圖的,下午一看前端大佬們人真的多,寫了這篇水文居然這麼多人點贊,於是我迫不及待的補上這個柱狀圖哈哈。非常感謝前端大佬們。第一次寫 js 內容有問題地方多多指出多多見諒。
image.png
1、繪製 X 軸和刻度。看代碼註釋
let marginLeft=80
let marginBootom=100
function translateCanvas(context) {
//畫布變換到左下角。
context.translate(marginLeft,canvas.height-marginBootom)
context.scale(1,-1)
}
//繪製X線和刻度等
function drawXLine(context) {
//繪製X軸
context.beginPath();
//起始點
context.moveTo(0,0)
//X軸結束點
context.lineTo(canvas.width-marginLeft*2,0)
context.closePath();
context.strokeStyle = 'rgb(0,0,0)';
context.shadowBlur = 2;
context.lineWidth=0.1
context.shadowColor = 'rgb(100,255,255)';
context.stroke()
//繪製平行線
context.save()
//計算每一份單位寬度
let heightOne=(canvas.height-marginBootom*2)/7
for (let index=0; index<8; index++){
context.beginPath();
context.moveTo(0,0)
context.lineTo(canvas.width-marginLeft*2,0)
context.closePath();
context.strokeStyle = 'rgb(0,0,0)';
context.shadowBlur = 2;
context.lineWidth=0.1
context.shadowColor = 'rgb(100,255,255)';
context.stroke()
context.translate(0,heightOne)
}
context.restore()
//繪製刻度
context.save()
//計算每一份單位寬度
let widthOne=(canvas.width-marginLeft*2)/20
for (let index=0; index<21; index++){
context.beginPath();
context.moveTo(0,0)
context.lineTo(0,-5)
context.closePath();
context.strokeStyle = 'rgb(0,0,0)';
context.lineWidth=0.1
context.stroke()
context.translate(widthOne,0)
}
context.closePath()
context.restore()
}
複製代碼
效果如下:
image.png
2、繪製 X 軸下面的文字。看代碼註釋
這裏有多一點繪製文字通過 measureText 進行測量即可如何講一個文字繪製到刻度中間呢?
image.png
let xText=["北京","天津","河北","山西","四川","廣東","上海","深圳","江蘇","河南","山西",
"陝西","甘肅","內蒙","天塌","運城","哈爾濱","日本","臺灣","香港"]
//繪製底部文字
function drawXDownText(context){
//繪製刻度
context.save()
context.scale(1,-1)
context.beginPath();
//計算每一份單位寬度
let widthOne=(canvas.width-marginLeft*2)/20
for (let index=0; index<xText.length; index++){
const textWidth = context.measureText(xText[index]);
//計算文字的文字,自己畫圖看看很簡單就是相對位置剪來剪去
context.strokeStyle = 'rgb(111,11,111)';
context.fillText(xText[index],widthOne/2-textWidth.width/2,textWidth.emHeightAscent)
context.stroke()
context.translate(widthOne,0)
}
context.restore()
//繪製Y軸左邊的文字
context.save()
//這裏很重要,爲了字體不反轉
context.scale(1, -1)
context.translate(-30, 0)
context.font = "7pt Calibri";
let heightOne=(canvas.height-marginBootom*2)/7
//Y軸左邊繪製文字
for (let i = 0; i < 8; i++) {
//畫不是閉合區域 fill是閉合區域
context.stroke()
//測量文字
const textHeight = context.measureText((3000 * i).toString());
//設置文字繪製的位置
context.fillText((3000 * i).toString(), 0, textHeight.emHeightAscent / 2);
//每次繪製完之後繼續往上平移
context.translate(0, -heightOne)
}
context.restore()
}
複製代碼
image.png
3、繪製巨型和造數據
⭐️⭐️⭐️⭐️⭐️⭐️這裏比較重要的一點,評論區也提到了,如何將實際的數據於座標系結合。例如我們實際的數據來自於後臺都是幾千幾萬。而我們的座標系高度緊緊 500px。其實簡單的運算也就是一個單位的數字佔實際像素多高 danwei=500 / 最大值 (例如 2000) 即可。那 12000*danwei 就是 12000 應該在實際畫布中的位置。
let datas=[[100,2800,9000],[150,2900,600],[300,12000,400],[500,13333,4000],[1300,2000,122],[111,3333,1111],[1111,2111,1111],[111,1611,222],[444,4444,333],[222,11111,2222],[2222,2235,11],[111,1345,1111],[1111,11111,2234],[1122,12223,12],[121,1665,111],[234,334,21]
,[112,12134,1211],[1212,12111,134],[124,2021,112],[1222,20345,1212],[1412,17771,1111],[111,12222,1111],[1123,121333,1111],[11112,11212,111],]
//繪製巨型等條狀物
function drawRect(context) {
//繪製刻度
context.save()
context.beginPath();
//計算每一份單位寬度
let widthOne=(canvas.width-marginLeft*2)/20
let widthOneRect=widthOne/3
let heightOne=(canvas.height-marginBootom*2)/7
let danwei=heightOne/3000
for (let index=0; index<xText.length; index++){
//計算文字的文字,自己畫圖看看很簡單就是相對位置剪來剪去
context.fillStyle = 'rgb(189,119,119)';
context.fill()
//第一個條紋
context.fillRect(0,0,widthOneRect-1,datas[index][0]*danwei)
context.fillStyle = 'rgb(19,172,172)';
//第二個條紋
context.fillRect(widthOneRect,0,widthOneRect-1,datas[index][1]*danwei)
context.fillStyle = 'rgb(111,73,142)';
//第三個條紋
context.fillRect(widthOneRect*2,0,widthOneRect-1,datas[index][2]*danwei)
context.translate(widthOne,0)
}
context.restore()
}
複製代碼
image.png
後面有時間會多補上其他的.....
5、雷達系列圖
如果上面你都按照我敲下來, 我想自定義對你來說不再是難事, 下面我們通過案例來看看自定義簡單的 API 加初中簡單的數學計算能給我們帶來什麼呢?
繪製之前的分析
座標變換到屏幕中心帶來的方便
繪製多條骨架線段
如何實際數據映射到屏幕中
連線填充完成
1、座標變換
如上圖, 如果圓點在屏幕中心會帶來很方便的操作。直接上代碼.... 不清楚看上面的座標變換部分
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>雷達圖</title>
</head>
<body>
<canvas id="canvas" style="background: black"></canvas>
</body>
<script>
const canvas = document.getElementById("canvas");
canvas.width = 831;
canvas.height = 706;
//繪製的對象獲取
const context = canvas.getContext("2d")
context.translate(canvas.width/2,canvas.height/2)
context.scale(1,-1)
//繪製圓
context.arc(0,0,50,0,Math.PI*2,true)
context.strokeStyle="rgb(189,142,16)"
context.stroke()
</script>
</html>
複製代碼
image.png
2、繪製多條骨架線段
我們看到總共有三條骨架直線將屏幕分爲六等分, 我們可以簡單的求出三條線段的方程式吧?初中的數學我相信你能明白。
Yx=-tan30*x
Yx= tan30*x
image.png
let y1= Math.tan(Math.PI/180*30)*(-300)
let y2= Math.tan(Math.PI/180*30)*300
context.moveTo(-300,y1)
context.lineTo(300,y2)
context.closePath();
context.strokeStyle="rgb(189,142,16)"
context.stroke()
複製代碼
image.png
補全了
context.beginPath();
//左邊一條骨架線段
let y1= Math.tan(Math.PI/180*30)*(-300)
let y2= Math.tan(Math.PI/180*30)*300
context.moveTo(-300,y1)
context.lineTo(300,y2)
context.strokeStyle="rgb(189,142,16)"
context.stroke()
//中間骨架線
context.moveTo(0,300)
context.lineTo(0,-300)
//右邊一條骨架線段
let y11= -Math.tan(Math.PI/180*30)*(-300)
let y22= -Math.tan(Math.PI/180*30)*300
context.moveTo(-300,y11)
context.lineTo(300,y22)
context.strokeStyle="rgb(189,142,16)"
context.stroke()
context.closePath();
複製代碼
image.png
發散的圓
for (let i = 0; i < 6; i++) {
context.beginPath();
//繪製圓
context.arc(0,0,50*(i+1),0,Math.PI*2,true);
context.strokeStyle="rgb(189,142,16)";
context.stroke();
context.closePath();
}
複製代碼
image.png
3、如何實際數據映射到屏幕中
同樣我們圓的半徑可以看做是各個骨架座標軸的長度, 而我們實際數據是長度數據而已如何將長度數字映射到各個不規則的骨架座標軸上呢?當然還是離不開簡單的數學。例如我們一個數字 250 如下圖兩個白色虛線相交地方。我們實際的 250 代表的是圓點到焦點部分的長度。但是我們需要在座標系中定位那就需要求出 (x,y) 在座標系中的虛擬座標。同樣的簡單的初中數學, 不難得出(x,y)=(length_cson30,lenght_sin30), 如果你細心分析每個骨架座標軸上的所有座標都滿足(x,y)=(length_cson30,lenght_sin30)。接下來我們上代碼看效果
image.png
//繪製網線填充
const datas = [[70, 100, 20, 5, 21, 99],[100, 120,50, 75, 121, 99],[117,211,259,232,190,200],[217,240,259,282,190,120]];
for (let i = 0; i < datas.length; i++) {
for (let index=0;index<datas[i].length;index++){
context.beginPath()
//右上角開始順時針開始繪製
context.lineTo(datas[i][0]*Math.cos(Math.PI/180*30),datas[i][0]*Math.sin(Math.PI/180*30))
context.lineTo(datas[i][1]*Math.cos(Math.PI/180*30),-datas[i][1]*Math.sin(Math.PI/180*30))
context.lineTo(0,-datas[i][2])
context.lineTo(-datas[i][3]*Math.cos(Math.PI/180*30),-datas[i][3]*Math.sin(Math.PI/180*30))
context.lineTo(-datas[i][4]*Math.cos(Math.PI/180*30),datas[i][4]*Math.sin(Math.PI/180*30))
context.lineTo(0,datas[i][5])
context.fillStyle="rgba(189,142,16,0.09)"
context.fill()
context.closePath();
}
}
//繪製網線邊緣線條
for (let i = 0; i < datas.length; i++) {
for (let index=0;index<datas[i].length;index++){
context.beginPath()
//右上角開始順時針開始繪製
context.lineTo(datas[i][0]*Math.cos(Math.PI/180*30),datas[i][0]*Math.sin(Math.PI/180*30))
context.lineTo(datas[i][1]*Math.cos(Math.PI/180*30),-datas[i][1]*Math.sin(Math.PI/180*30))
context.lineTo(0,-datas[i][2])
context.lineTo(-datas[i][3]*Math.cos(Math.PI/180*30),-datas[i][3]*Math.sin(Math.PI/180*30))
context.lineTo(-datas[i][4]*Math.cos(Math.PI/180*30),datas[i][4]*Math.sin(Math.PI/180*30))
context.lineTo(0,datas[i][5])
context.strokeStyle="rgb(189,142,16)"
context.stroke()
context.closePath();
}
}
複製代碼
image.png
掘金文字限制, 會另起篇章!!!!!
五、總結
對於我一個基本沒使用過 HTML5 和 JS 移動端人員來說一天的功夫就能夠完成這些內容。對於前端的大佬們來說簡單不過來, 當然了奇怪的是我也問過很多前端開發人員, 對於自定義也是一知半解, 不夠深入,當我問起圖表,ECharts 總是頻頻出口,可能有些公司的 UI 不是太嚴格,開發基本在 Echarts 裏面尋找類似圖表或者設計人員直接基於 ECharts 進行選擇設計, 可能是種種原因國內的 UI 基本趨向於同一, 技術在變革, UI 設計也應該別具一格,有所特色。當然了我們最好有自定義的能力豈不更好?當你遇到不常見的設計你可以隨心所欲,那是一件多麼美好的事情。
我想下面這些圖表, 無非巨型、弧度、等加畫布變換、填充漸變、文字繪製吧?是時候展示你的技術了
image.png
當然了 ECharts 裏面有很多自定義的內容。如果你認真手敲了這篇文字,你就應該對於自定義內容胸有成竹。至於好的操作當然離不開手勢和動畫,後面我們來開始手勢加動畫來逐漸過渡到K線,沒錯就是 K 線。
k 線圖
關於本文
作者:路很長 OoO
https://juejin.cn/post/6950684708443258894
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/krCm6J_O5SCtDwudGNKHWQ