🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

Canvas 高性能K線圖,支持無限左右滑動

K線圖

前言

  1. 之前在證券行業,接觸過移動端K線圖的需求;
  2. H5 K線圖,支持無限左右滑動,支持樣式自定義;
  3. 纯canvas製作,不借助任何第三方圖表庫;
  4. 閱讀本文,需要有canvas基礎知識。

滑動K線圖組件    Github Page 預覽地址

股票詳情頁源碼    Github Page 預覽地址

注意:以上的demo還有一些bug,沒時間修復,另外預覽地址是直接在github上部署的,所以最好通過vpn科學上網,否則可能訪問不了。還有,上面的股票詳情頁,還沒有做自適應,等我有時間再改。

一、先看最終的效果

1、GIF動圖如下

gif222.gif
gif.gif

2、支持樣式自定義

用屏幕取色器,獲取東方財富的配色 codeinword.com/eyedropper

圖一、圖二,是參考東方財富黑白皮膚的配色,圖三是參考騰訊自選股的配色。

q1.png
q2.png
q3.png

二、canvas 注意事項

1、整數坐標,會導致模糊

canvas在畫線段,通常會出現以下代碼:

cxt.moveTo(x1, y1);
cxt.lineTo(x2, y2);
cxt.lineWidth = 1;
cxt.stroke();

假設上面的兩個點是(1,10)和(5,10),那麼畫出來的實際上是一條橫線,理論上橫線的粗度是1px,且該橫線被y=10切成兩半,上半部分粗度是0.5px,下半部分粗度也是0.5px,這樣橫線的整體粗度才會是1px。

但是canvas 不是這樣處理的,canvas默認線條會與整數對齊,也就是橫線的上部分不會是y=9.5px,而是y=9px;橫線的下半部分也不是y=10.5px,而是y=11px;從而橫線的粗度看起來不是1px,而是2px。

並且由於粗度被拉伸,顏色也會被淡化,那怎麼解決這個問題呢?

處理方式也很簡單,通過cxt.translate(0.5, 0.5)將坐標往下移動0.5個像素,然後接下來的所有點,都保證是整數即可,這樣就能保證不會被拉伸。

典型的代碼如下:

cxt.translate(0.5, 0.5);
cxt.moveTo(Math.floor(x1), Math.floor(y1));
cxt.lineTo(Math.floor(x2), Math.floor(y2));
cxt.lineWidth = 1;
cxt.stroke();

在我的代碼中,也體現了類似的處理。

2、如何處理高像素比帶來的模糊

設備像素比越高,理論上應該越清晰,因為原來用一個小方塊來渲染1px,現在用2個小方塊來渲染,應該更清晰才對,但是canvas 不是這樣的。

例如,通過js獲取父容器div的寬度是width,這時如果設置canvas.width = width,在設備像素比為2的時候,canvas畫出來的寬度為css對應寬度的一半,如果強制通過css將canvas寬度設置為width,則canvas會被拉長一倍,導致出現齒狀模糊。

注意了嗎?上面所說的canvas.width=width與css設置的#canvas { width: width }起到的效果是不一樣的。不要隨便通過css去設置canvas的寬高,容易被拉伸變形或者導致模糊。

通用的處理方式是:

//初始化高清Canvas
function initHDCanvas() {
  const rect = hdCanvas.getBoundingClientRect();

  //設置Canvas內部尺寸為顯示尺寸乘以設備像素比
  const dpr = window.devicePixelRatio || 1;
  hdCanvas.width = rect.width * dpr;
  hdCanvas.height = rect.height * dpr;

  //設置Canvas顯示尺寸保持不變
  hdCanvas.style.width = rect.width + 'px';
  hdCanvas.style.height = rect.height + 'px';

  //獲取上下文並縮放
  const ctx = hdCanvas.getContext('2d');
  ctx.scale(dpr, dpr);
}

三、樣式配置

為了方便樣式自定義,我獨立出一個默認的配置對象defaultKlineConfig,參數的含義如下圖所示,其實下圖這個風格的標註,是通過 excalidraw 這個軟件畫的,也是canvas做的開源軟件,可見canvas在前端可視化領域的重要性,這個扯遠了,打住。

333.png

如上圖,整個canvas畫板,分成5部分,每一部分的高度,都可以設置,其中主圖和副圖的高度,是通過比例來計算的:mainChartHeight = restHeight * mainChartHeightPercent,其中,restHeigh是畫板總高度height減去其他幾部分計算的,如下:restHeight = height - lineMessageHeight - tradeMessageHeight - xLabelHeight

十字交叉線的顏色,X軸與Y軸的tooltip背景色、字體大小的參數如下:

7777.png

四、均線計算

從上面的圖可以看出,需要畫5日均線、10日均線、20日均線,成交量快線(10日)、成交量慢線(20日)但是,接口沒有給出當日的均線值,需要自己計算。

5日均線 = (過去4個成交日的收盤價總和 + 今日收盤價)/ 5

10日均線 = (過去9個成交日的收盤價總和 + 今日收盤價)/ 10

20日均線 = (過去19個成交日的收盤價總和 + 今日收盤價)/ 20

成交量快線 = (過去9日成交量 + 今日成交量)/ 10

成交量慢線 = (過去19日成交量 + 今日成交量)/ 20

所以,當獲取lmt(一屏的蠟燭圖個數)個數據時,為了計算均線,需要至少將前19個(我的代碼寫20)數據都獲取到。當一個均線已經獲取到,下一個均線就不需要再累加20個值再得平均數,可以省一點計算:

*今日20日均線值 = (昨日均線值 20 - 前面第20個的收盤價 + 今日收盤價)/ 20;**

五、分層渲染

為了減少重繪,提高性能,可以將K線圖做分層渲染。那分幾層合適?我認為是三層。

  1. 第一層,不動層
  2. 第二層,變動層
  3. 第三層,交互層

不動層

首先,網格是固定的,也就是說,當頁面拖拽、或者長按出現十字交叉的時候,底部的網格線是不變的,如果每次拖拽,都需要重繪網格,那這個其實是沒有必要的開銷,可以將網格放在最底層,一次性繪製後,就不要再重繪。

變動層

由於拖拽的時候,蠟燭柱體、均線、Y軸刻度,X軸刻度,都需要重繪,這一塊是無法改變的事實,所以,變動層放在中間層,也是最繁忙的一層,並且該層不響應觸摸事件,觸摸事件交給交互層。

交互層

交互層監聽觸摸事件:當頁面快速滑動,則響應拖拽事件,即K線圖的時間線會左右滑動;當用戶長按之後再滑動,則出現十字交叉浮層。

交互層的好處是,當響應十字交叉浮層時,只需要繪製橫線、豎線、對應X軸和Y軸的值,而不需要重繪蠟燭柱體和均線,可以減少重繪,最大程度減少渲染壓力。

六、基礎幾何繪製

網格線

首先計算出主圖的高度this.mainChartHeight,將主圖從上到下等分為4部分,再在左右兩邊畫出豎線,形成主圖的網格,副圖是成交量圖,只需畫一個矩形邊框即可,用strokeRect即可畫出。

private drawGridLine() {
  //獲取配置參數
  const { gridColor, lineMessageHeight, xLabelHeight, width, height } = this.config;
  //畫出K線圖的5條橫線
  const split = this.mainChartHeight / 4;
  this.canvasCxt.beginPath();
  this.canvasCxt.lineWidth = 0.5;
  this.canvasCxt.strokeStyle = gridColor;
  for (let i = 0; i <= 4; i++) {
    const splitHeight = Math.floor(split * i) + lineMessageHeight!;
    this.drawLine(0, splitHeight, width, splitHeight);
  }
  //畫出K線圖的2條豎線
  this.drawLine(0, lineMessageHeight!, 0, lineMessageHeight! + this.mainChartHeight);
  this.drawLine(width, lineMessageHeight!, width, lineMessageHeight! + this.mainChartHeight);
  //畫出成交量的矩形
  this.canvasCxt.strokeRect(
    0,
    height - xLabelHeight! - this.subChartHeight,
    width,
    this.subChartHeight,
  );
}

畫各類均線

  1. 首先計算出一屏的股價最大值max,股價最小值min,成交量最大值maxAmount

  2. 當某一個點的均線為value,根據最大值、最小值、索引index,計算出坐標點(x, y),畫均線的時候,第一個點用moveTo(x0, y0),其他點用lineTo(xn yn),最後stroke連起來即可。

  3. 當然,每一條線設置下顏色,即strokeStyle

//畫出各類均線
private drawLines(max: number, min: number, maxAmount: number) {
  //將寬度分成n個小區間,一個小區間畫一個蠟燭,每個區間的寬度是splitW
  const splitW = this.config.width / this.config.lmt!;
  //畫一下5日均線
  this.canvasCxt.beginPath();
  this.canvasCxt.strokeStyle = this.config.ma5Color;
  this.canvasCxt.lineWidth = 1;
  let isTheFirstItem = true;
  for (
    let i = this.startIndex;
    i < this.arrayList.length && i < this.startIndex + this.config.lmt!;
    i++
  ) {
    const index = i - this.startIndex;
    let value = this.arrayList[i].ju5;
    if (value === 0) {
      continue;
    }
    const x = Math.floor(index * splitW + 0.5 * splitW);
    const y = Math.floor(
      ((max - value) / (max - min)) * this.mainChartHeight + this.config.lineMessageHeight!,
    );
    if (isTheFirstItem) {
      this.canvasCxt.moveTo(x, y);
      isTheFirstItem = false;
    } else {
      this.canvasCxt.lineTo(x, y);
    }
  }
  this.canvasCxt.stroke();
}

畫出蠟燭柱體

666.png
999.png

當收盤價大於等於開盤價,選用上面左邊紅色的樣式;當收盤價小於開盤價,選用上面右邊綠色的樣式。

以紅色蠟燭為例,最高點A(x0, y0),最低點是B(x1, y1),高度height、寬度width都是相對於坐標軸的,紅色矩形左上角的頂點是D(x, y)。

為了畫出紅色蠟燭,先後順序別搞混:

  1. AB這條豎線,通過moveTolineTo畫出來;
  2. 定義一個矩形cxt.rect(x, y, width, height)
  3. 通過fill填充白色背景,同時覆蓋後面的紅色豎線;
  4. 再通過stroke描出紅色邊框。

按照上面這個順序,豎線會被覆蓋掉,同時,白色填充不會擠壓紅色邊框,如果先strokefill,容易出現白色填充覆蓋紅色邊框,可能會變模糊,或者使得紅色變淡,相當不友好,所以按照我上面的順序,可以減少不必要的麻煩。

畫出文字

canvas畫出文字,典型的代碼如下

this.canvasCxt.beginPath();
this.canvasCxt.font = `${this.config.yLabelFontSize}px "Segoe UI", Arial, sans-serif`;
this.canvasCxt.textBaseline = 'alphabetic';
this.canvasCxt.fillStyle = this.config.yLabelColor;

注意textBaseline默認對齊方式是alphabetic,但middle往往更好用,能實現垂直居中,但我發現垂直居中也不是很居中,所以會特意加減1、2個像素;

當然還有個textAlign,能實現水平對齊方式,左右對齊都可以,例如上圖最左、最右的時間標籤。

七、交互設計

根據上面的GIF動圖,可以知道,本次做的移動端K線圖,最重要的兩個交互是:

  1. 快速拖拽,K線圖隨時間軸左右滑動
  2. 長按滑動,出現十字交叉tooltip

上面的交互,其實是比較複雜的,所以需要先設計一個簡單的數據結構:

  1. 首先頁面存放一個列表arrayList
  2. 保存一個數字標識startIndex,表示當前螢幕從startIndex開始畫蠟燭圖

當用戶往右快速拖拽時,startIndex根據用戶拖拽的距離,適當變小;當用戶往左快速拖拽時,startIndex根據用戶拖拽的距離,適當變大。

arrayList到底多長合適,因為股票可能有十幾年的數據,甚至上百年的數據,我不能一次性拉取這個股票的所有數據吧?

當然,站在軟體性能、消耗等角度,也不應該一次性拉取所有的數據,我的答案是arraylist最多保存5屏的數據量,用戶看到的螢幕,應該是接近中間這一屏,也就是第3屏的數據,左右兩邊各保存2屏數據,這樣,用戶拖拽的時候,可以比較流暢,而不是每次拖拽都要等拉取數據再去渲染。

那什麼時候拉取新的數據呢?
用戶觸摸完後,當startIndex左邊的數據少於2屏,開始拉取左邊的數據;
用戶觸摸完後,當startIndex右邊的數據少於2屏,開始拉取右邊的數據;

那如果用戶一直往右拖拽,是不是就一直往左邊添加數據,這個arraylist是不是會變得很長?

當然不是,例如,當我往arraylist的左邊添加數據的時候,startIndex也會跟著變動,因為用戶看到的第一條柱體,在arraylist的索引已經變了。當我往arraylist的某一邊添加數據後,arraylist的另一邊如果數據超過2屏,要適當裁掉一些數據,這樣arraylist的總數,始終保持在5屏左右,就不會佔用太多的存放空間。

總體思想是,從startIndex開始繪製螢幕的第一條柱體,當前螢幕的左右兩邊,都預留2屏數據,防止用戶拖拽出發頻繁拉取數據,導致卡頓;同時也控制了arraylist的長度,這是虛擬列表的變形,這樣設計,可以做一個高性能的K線圖。

八、觸摸事件解耦

根據上面的分析:

  1. 快速拖拽,K線圖左右移動
  2. 長按再滑動,出現十字交叉tooltip

以上兩種拖拽,都在touchmove事件中觸發,那怎麼區分開呢?
典型的touchstarttouchmovetouchend解耦如下:

let timer = null;
let startX = 0;
let startY = 0;
let isLongPress = false;

canvas.addEventListener('touchstart', (e) => {
    startX = e.touches[0].clientX;
    startY = e.touches[0].clientY;
    isLongPress = false;

    timer = setTimeout(() => {
        isLongPress = true;
        // 顯示十字光標hover
        showCrossHair(e);
    }, 500);
});

canvas.addEventListener('touchmove', (e) => {
    if (isLongPress) {
        // 長按移動時更新十字光標位置
        updateCrossHair(e);
    } else {
        // 快按拖動時移動K線圖
        clearTimeout(timer);
        moveKLineChart(e);
    }
});

canvas.addEventListener('touchend', () => {
    clearTimeout(timer);
    if (isLongPress) {
        // 長按結束隱藏十字光標
        hideCrossHair();
    }
    isLongPress = false;
});

// 关闭十字光标
function hideCrossHair() {
    // 隱藏邏輯
}

根據上面的框架,再詳細補充下代碼就可以了。
然後再在touchend事件中,新增或減少arraylist的數據量。

九、性能優化

其實,做到上面的設計,性能已經很好了,可以監控幀率來看下滑動的流暢程度。

總結下為什麼高性能:

分層渲染

將K線圖畫在3個canvas上。

  1. 不動層只需要繪畫一次;
  2. 變動層根據需要而變動;
  3. 交互層獨立出來,不會影響其它層,變動層的大量蠟燭柱體、均線等也不會受交互層的影響

離屏預渲染

當需要在K線上標註一些icon時,這些icon可以先離屏渲染,需要的時候,再copy到變動層對應的位置,這樣比臨時抱佛腳去畫,要省很多時間,也能提高性能。

設置數據緩衝區

就是螢幕只渲染一屏數據,但在當前屏的左右兩邊,各緩存了2屏數據,超過5屏數據的時候,及時裁掉多餘的數據,這樣arraylist的數據量始終保持在5屏,有效的控制了佔用空間。

節流防抖

touchmove會很頻繁觸發,可通過節流來控制,減少不必要的渲染。

十、部署到GitHub Pages

1、安裝gh-pages包

npm install --save-dev gh-pages

2、package.json 添加如下配置

注意,Stock這個需要對應github的倉庫名

{
  "homepage": "https://fhrddx.github.io/Stock",
  "scripts": {
    "predeploy": "npm run build",
    "deploy": "gh-pages -d build"
  }
}

3、運行部署命令

npm run build
npm run deploy

1.png

最後,訪問上面的鏈接(注意,在國內可能要開vpn)

fhrddx.github.io/Stock/

這樣,github pages部署成功,訪問上面鏈接,可以看到如下效果。

2.png


原文出處:https://juejin.cn/post/7556154928059334666


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝19   💬9   ❤️5
728
🥈
我愛JS
📝4   💬14   ❤️7
250
🥉
御魂
💬1  
3
#5
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付