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