JS前端使用canvas動態(tài)繪制函數(shù)曲線示例詳解
前言
不說廢話,我們直入主題。先來看看讀了這篇文章你將得到什么,就是下面這個東西啦??(是不是很清晰很順滑):
那具體要做什么呢,我們來簡單拆解一下步驟:
- 繪制坐標(biāo)系
- 繪制多條函數(shù)曲線
- 繪制輔助線和坐標(biāo)點
- 支持平移、縮放
- 支持動態(tài)繪制曲線
- 使之高清 下面就來一一攻克它們,如果你在電腦前可以順便瞅瞅示例代碼(傳送門),不依賴任何庫哦。
第一步:繪制坐標(biāo)系
既然我們要畫函數(shù),那肯定要先搞一個坐標(biāo)系啦,也就是網(wǎng)格。大家可能都知道怎么畫網(wǎng)格,就是橫的畫幾條、豎的畫幾條。思路是沒錯,但是具體畫起來要考慮的問題還是有很多的。畫一個不動的、沒有要求的網(wǎng)格很簡單,但加了刻度后就不那么容易了。這里我就直接說下會遇到的一些問題吧!
1、如何確定 x 軸和 y 軸的邊界值
這個東西很重要,是接下來所有一切操作的開端,因為我們畫的是函數(shù),一定要有個 x 軸的邊界(當(dāng)做參數(shù)傳進(jìn)來),那 y 軸呢,一般我們的網(wǎng)格都是正方形,所以這里讓 y 軸的取值范圍跟 x 軸一致即可。如果你不傳 x 軸的兩個邊界值(lefxX、rightX),那我們會給個默認(rèn)值[-canvas寬度的一半/100, canvas寬度的一半/100],因為一般畫布的寬高都是幾百,所以這里就簡單的除以 100,你要除以 10 也是 ok 的。再次強調(diào)一下邊界值這個東西很重要,因為接下來的操作都是基于邊界值來繪制的。不信你可以嘗試一下沒有邊界值的情況,那可能會無從下手??。
2、不是傳入多少網(wǎng)格數(shù)就是多少網(wǎng)格
因為網(wǎng)格刻度是需要騷微取整的,比如 10、5、1、0.5、0.2、0.1 這樣。什么意思呢,舉個具體的例子??,比如 x 軸的取值范圍是 (-5,5),網(wǎng)格的數(shù)量是10,那剛好就可以有 10 個網(wǎng)格,這樣刻度就會挺整的(-5,-4,-3這樣);如果網(wǎng)格數(shù)是9,那么我們也會有10個網(wǎng)格,刻度也是(-5,-4,-3這樣),而不是(-5,-3.9,-2.8),不知道大家 get 到木有。所以網(wǎng)格大小是要稍微計算一下的,算是個小小的算法,下面是其中的一種解法????:
/** * 計算每一個網(wǎng)格的寬高大小,注意并不是 gridCount 是多少就會有多少網(wǎng)格 * 因為我們的坐標(biāo)刻度通常都是 10、5、1、0.5、0.1 這個樣子,大部分都是以 2、5、10 的倍數(shù) * @param len x 或 y 軸代表的長度 * @param gridCount 網(wǎng)格個數(shù) */ calcGridSize(len: number, gridCount: number): number[] { let gridWidth = 1; // 應(yīng)該保留幾位小數(shù),避免浮點數(shù) let fixedCount = 0; // 事實上,要是圖方便的話,你也可以直接用 unitX 來當(dāng)做網(wǎng)格大小,不過記得要取整 let unitX = len / gridCount; // 而這里呢,我們需要找到離 unitX 最近的(稍微偏整數(shù)的)值 // 首先要找到離 unitX 最近的 10 的整數(shù)倍,如 0.01、0.1、1、10、100 while (gridWidth < unitX) { gridWidth *= 10 } while (gridWidth / 10 > unitX) { gridWidth /= 10 fixedCount++; } // 看看能不能再劃分一次,如(/5 得到 0.02、0.2、20)、(/2 得到 0.05、5、50) if (gridWidth / 5 > unitX) { gridWidth /= 5; fixedCount++; } else if (gridWidth / 2 > unitX) { gridWidth /= 2; fixedCount++; } // 因為 x 軸長度和 y 軸的長度是一樣的,所以可以這樣賦值 return [gridWidth, gridWidth, fixedCount]; }
3、如何讓坐標(biāo)原點位于畫布中心
這個比較抽象,所以我們看圖。我們不要這樣(注意看四周的網(wǎng)格):
我們要這樣(注意看四周的網(wǎng)格,再和上圖對比一下):
不知道大家 get 到木有,其實你可以從中間開始往上下、往左右畫網(wǎng)格,不過這里我們還是從左到右畫就行(做點簡單的數(shù)學(xué)運算),直接看代碼應(yīng)該會清晰點,而且我還配了圖??:
drawGrid() { const { width, height, leftX, rightX, leftY, rightY, xLen, yLen, gridCount, ctx2d } = this; ctx2d?.save(); // 注意這里我們是將網(wǎng)格數(shù)作為配置項,而不是網(wǎng)格的寬高大小 const [gridWidth, gridHeight, fixedCount] = this.calcGridSize(xLen, gridCount); // 由于計算會產(chǎn)生浮點數(shù)偏差,所以要控制下小數(shù)點后面的數(shù)字個數(shù) this.fixedCount = fixedCount; // 從左到右繪制豎線 for (let i = Math.floor(leftX / gridWidth); i * gridWidth < rightX; i++) { // 繪制像素點 / 整個畫布寬度 = 實際 x 值 / 實際表示的 x 軸長度 const x = (i * gridWidth - leftX) / xLen * width; // i = 0 就說明是 y 軸,顏色加深 const color = i ? '#ddd' : '#000'; this.drawLine(x, 0, x, height, color) this.fillText(String(this.formatNum(i * gridWidth, this.fixedCount)), x, height, this.fontSize, TextAlign.Center); } // 繪制橫線也是和上面一樣的方法,就是要注意畫布的 y 軸向下,需要用 height 減一下,或者用 scale(1, -1); for (let j = Math.floor(leftY / gridHeight); j * gridHeight < rightY; j++) { let y = (j * gridWidth - leftY) / yLen * height; y = height - y; const color = j ? '#ddd' : '#000'; this.drawLine(0, y, width, y, color); this.fillText(String(this.formatNum(j * gridHeight, this.fixedCount)), 0, y, this.fontSize, TextAlign.Middle); } ctx2d?.restore(); } // 保留 fixedCount 位小數(shù),整數(shù)不補零 formatNum(num: number, fixedCount: number): number { return parseFloat(num.toFixed(fixedCount)); }
另外簡單說下為什么刻度要畫在邊上呢,不畫在軸上,因為只要你騷微移動或者放大下畫布,坐標(biāo)軸就不在視線范圍內(nèi)了,刻度也就看不見了,所以不如一開始就放邊上。
4、刻度總是會有浮點數(shù)
刻度和坐標(biāo)點這種東西總是要做些加減乘除的運算,自然而然就會遇到我們前端知名的浮點數(shù)問題,所以這個是需要處理一下的,比如保留幾位小數(shù)。當(dāng)然關(guān)于浮點數(shù)的運算是有庫可以用的,大致的思路分為兩種:
- 變成整數(shù)在計算(先把數(shù)字都乘以10的n次方,最后除以10的n次方)
- 變成字符串來計算(類似于小學(xué)數(shù)學(xué)的計算方式,比如乘法,與個位、十位、百位...分別相乘再相加)
第二步:畫函數(shù)曲線
畫函數(shù)曲線的思路很直白,就是以直代曲,將多個點用線連起來就行了,我們會先通過 y = fn(x) (fn 大概長這樣 sin(x)、1 / x 這樣)來算出函數(shù)的坐標(biāo)點,然后將函數(shù)的坐標(biāo)點換算成畫布上的點,最后繪制出來。下面是一些要小小注意的點:
- 點越多畫出來的曲線就越平滑,這里我們可以通過參數(shù) steps 來控制,默認(rèn)值為 1。
- 計算出來的點可以不需要保存下來,因為你一移動、縮放,值就全變了。
- 記得在繪制每條函數(shù)開始前 beginPath 一下,否則會影響到下一條曲線的繪制(比如顏色、線寬)。
- 對于畫布外的點我們是不需要繪制的,直接用 moveTo 即可(但不是用 continue 跳過)。
/** 繪制函數(shù)曲線,就是用一段段直線連起來 */ drawFn() { const { width, height, leftX, leftY, xLen, yLen, steps, ctx2d } = this; if (!ctx2d) return; ctx2d.save(); this.fnList.forEach(fn => { ctx2d.strokeStyle = (fn as any).color; ctx2d.beginPath(); for (let i = 0; i < width; i += steps) { // 小小的公式推導(dǎo):像素點 / 畫布寬 = x / 實際表示的 x 軸長度 const x = i / width * xLen + leftX; let y = fn(x); if (isNaN(y)) continue; // 換算到具體繪制點 y = height - (y - leftY) / yLen * height; // 在畫布之外是不用繪制的所以用 moveTo 即可 if (i === 0 || y > height || y < 0) { ctx2d.moveTo(i, y); } else { ctx2d.lineTo(i, y); } } ctx2d.stroke(); }); ctx2d.restore(); }
這里想強調(diào)一點的就是在繪制前后時刻記得要 save 和 restore,并且這兩個方法要配對使用。我們都應(yīng)該聽過 canvas 是基于狀態(tài)管理的,如果你想要畫一條紅色的線,我們需要把畫筆設(shè)置成紅色,不使用 save 和 restore 的話,這個紅色會影響到接下來的所有操作,所以一般我們要養(yǎng)成下面這樣的習(xí)慣(尤其是平移、旋轉(zhuǎn)和縮放等操作,不然一臉懵逼不是夢??):
// 初始狀態(tài) ctx2d.save(); // 在這里設(shè)置各種狀態(tài)1 ctx2d.save(); // 在這里設(shè)置各種狀態(tài)2 ctx2d.restore(); // 這里是狀態(tài)1 ctx2d.restore(); // 這里是初始狀態(tài)
之所以可以嵌套使用是因為 canvas 使用了棧(只能 push 和 pop 的數(shù)組)的方式來管理狀態(tài),就像這樣 [初始狀態(tài)的集合、狀態(tài)1的集合、狀態(tài)2的集合],save 就是將當(dāng)前狀態(tài)往數(shù)組末尾追加,restore 就是取出數(shù)組最后一項當(dāng)做當(dāng)前狀態(tài)。而集合你可以理解成是一個大對象,里面有各種屬性,形如 {strokeStyle, fillStyle, lineWidth} 這樣。
第三步:繪制輔助線和交點坐標(biāo)
這個很簡單,首先我們能夠知道鼠標(biāo)相對于畫布的坐標(biāo),然后換算成曲線的 x 值,代入每個函數(shù)中計算出一個 y 值,如果 y 是個有效值,那么就在點 [x, y]
處畫個圓即可,記得數(shù)字都格式化一下的。
drawSubLine(canvasPos: Point) { const ctx2d: CanvasRenderingContext2D | null = this.ctx2d; if (!ctx2d) return; const { width, height } = this; const { x, y } = canvasPos; // 先重新繪制網(wǎng)格和函數(shù)曲線 this.draw(); // 繪制輔助線 ctx2d.save(); this.drawLine(x, 0, x, height, '#999',true); this.drawLine(0, y, width, y, '#999', true); ctx2d.restore(); // 繪制鼠標(biāo)點 const centerRectLen: number = 8; this.strokeRect(x - centerRectLen / 2, y - centerRectLen / 2, centerRectLen, centerRectLen); const actualPos = this.canvasPosToFnVal(canvasPos); // 繪制曲線和輔助線的交點坐標(biāo) this.handleCrosspoint(actualPos.x); } handleCrosspoint(x: number) { const pointList: Point[] = this.checkCrosspoint(x); pointList.forEach(point => { const { x, y } = this.fnValToCanvasPos(point); this.fillCircle(x, y, 4, 'red'); this.fillText(`[${this.formatNum(point.x, this.fixedCount + 1)}, ${this.formatNum(point.y, this.fixedCount + 1)}]`, x, y, this.fontSize); }); }
繪制輔助線的時候我們總是會先重新繪制網(wǎng)格和曲線,這個是可以優(yōu)化的。我們可以進(jìn)行分層處理,因為鼠標(biāo)移動的時候,就只會改變輔助線和坐標(biāo)點,所以我們可以將網(wǎng)格和函數(shù)曲線放在下層,輔助線和坐標(biāo)點放在上層,這樣每次繪制的時候只要重新繪制上層就可以了。
第四步:平移
首先肯定是要加個事件監(jiān)聽的:
this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this), false); // 也可以把 mouseup 和 mousemove 這兩個監(jiān)聽寫在 mousedown 的回調(diào)中 document.addEventListener('mouseup', this.handleMouseUp.bind(this), false); document.addEventListener('mousemove', this.handleMouseMove.bind(this), false);
平移應(yīng)該是眾多操作中最簡單的一個,平移的結(jié)果就是改變了 x 軸和 y 軸的邊界值,所以只需要重新計算出邊界值,重新調(diào)用繪制函數(shù)即可。我們用 startPos 和 endPos 來計算平移的距離,就像下面這樣:
// 返回距離畫布左上角的點 viewportToCanvasPosition(e: MouseEvent): Point { const { clientX, clientY } = e; const { top, left } = this.canvas.getBoundingClientRect(); const x = clientX - top; const y = clientY - left; return new Point(x, y); } // 記錄鼠標(biāo)按下的點 handleMouseDown(e: MouseEvent) { const canvasPos: Point = this.viewportToCanvasPosition(e); this.state.startPos = canvasPos; } // 計算平移距離,更新 leftX 和 rightX 的值,然后重新繪制 handleMouseMove(e: MouseEvent) { const canvasPos: Point = this.viewportToCanvasPosition(e); if (!this.state.startPos) return; document.body.style.cursor = 'move'; this.state.endPos = canvasPos; const { width, height, xLen, yLen, state: { startPos, endPos } } = this; // 算出平移距離 const dx = (endPos.x - startPos.x) / width * xLen; const dy = (endPos.y - startPos.y) / height * yLen; // 更新邊界值 this.leftX -= dx; this.rightX -= dx; this.leftY += dy; this.rightY += dy; this.xLen = this.rightX - this.leftX; this.yLen = this.rightY - this.leftY; this.draw(); this.state.startPos = canvasPos; } // 還原拖拽前的狀態(tài) handleMouseUp(e: MouseEvent) { this.state.startPos = null; this.state.endPos = null; document.body.style.cursor = 'auto'; }
要注意的是如果我們往右平移,上面代碼中的 dx 應(yīng)該是正值,但刻度是減小的,所以計算的時候應(yīng)該用減號,其它方向同理。
第五步:縮放
首先就是要監(jiān)聽滾輪事件:
this.canvas.addEventListener('mousewheel', this.handleMouseWheel.bind(this), false);
同樣的,縮放的核心也是改變邊界值,但是這個推導(dǎo)會比平移騷微麻煩一些,自己動手畫個圖會比較清晰點。另外和平移不一樣的是,縮放應(yīng)該是以當(dāng)前點為中心進(jìn)行縮放,并且是一邊加一邊減。啥意思呢?就是比如你在畫布中心放大的時候,leftX 的值應(yīng)該是增加的,rightX 應(yīng)該是減少的。還有就是縮放應(yīng)該要有個限制,無限大或者無限小都沒啥太大意義。下面是代碼和圖的示例:
handleMouseWheel(e: Event) { e.preventDefault(); const event: WheelEvent = e as WheelEvent; const canvasPos: Point = this.viewportToCanvasPosition(event); const { deltaY } = event; const { leftX, rightX, leftY, rightY } = this; const scale: number = deltaY > 0 ? 1.1 : 0.9; if (this.isInvalidVal(scale)) return; const { x, y } = this.canvasPosToFnVal(canvasPos); // 注意縮放和平移不一樣,軸的左右兩邊一邊是加一邊是減 this.leftX = x - (x - leftX) * scale; this.rightX = x + (rightX - x) * scale; this.leftY = y - (y - leftY) * scale; this.rightY = y + (rightY - y) * scale; this.xLen = this.rightX - this.leftX; this.yLen = this.rightY - this.leftY; this.draw(); } /** 縮放過大過小都沒啥意義,所以設(shè)置下邊界值 */ isInvalidVal(ratio: number): boolean { const { xLen, yLen, MIN_DELTA, MAX_DELTA } = this; if (ratio > 1 && (xLen > MAX_DELTA || yLen > MAX_DELTA)) return true; if (ratio < 1 && (xLen < MIN_DELTA || yLen < MIN_DELTA)) return true; // 上面的判斷為什么不直接 (xLen > MAX_DELTA || yLen > MAX_DELTA || xLen < MIN_DELTA || yLen < MIN_DELTA)這樣判斷呢? // 因為如果這樣判斷你會發(fā)現(xiàn)縮放到最大和最小的時候,再繼續(xù)操作都是無效的。 return false; } canvasPosToFnVal(canvasPos: Point): Point { const { width, height, leftX, leftY, xLen, yLen } = this; const x = leftX + canvasPos.x / width * xLen; const y = leftY + canvasPos.y / height * yLen; return new Point(x, y); }
第六步:動態(tài)繪制曲線
既然你已經(jīng)會畫函數(shù)曲線了,那動態(tài)繪制也不在話下。就是用 requestAnimationFrame、setTimeout 或者 setInterval 一次畫一段,繪制的快慢可以通過調(diào)整時間參數(shù)或者每次畫多段來調(diào)整,然后有以下幾個注意點:
- 動畫的時候整個畫布應(yīng)該是不可操作的狀態(tài)。
- 我們的曲線是一條繪制完再繪制另一條的,而不是從左到右同時繪制每條曲線,當(dāng)然怎么做都可以。
- 有的曲線左邊開始部分沒有圖像,就會出現(xiàn)等待一段時間后才開始繪制的情況,所以我們應(yīng)該跳過不用繪制的點。
第七步:模糊到高清
其實寫到最后你會發(fā)現(xiàn)曲線好像有點模糊,事實上整個畫布都會有點模糊,這在 canvas 中是個很常見的問題,以上就是JS前端使用canvas動態(tài)繪制函數(shù)曲線示例詳解的詳細(xì)內(nèi)容,更多關(guān)于JS canvas繪制函數(shù)曲線的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章

Performance 內(nèi)存監(jiān)控使用技巧詳解

JS前端面試數(shù)組扁平化手寫flat函數(shù)示例