數據可視化:canvas與ECharts入門使用


本周准備一個分享,順便記錄一下入門時碰到的問題。

官方文檔:https://echarts.apache.org/zh/index.html

0.關於Echarts

ECharts.js是百度開源的一個數據可視化圖表庫。

2018年,全球著名開源社區 Apache 基金會宣布“百度開源的 ECharts 項目全票通過進入 Apache 孵化器”。這是百度第一個進入國際頂級開源社區的項目。

1.安裝

npm install echarts --save

2.引入

// 全部引入
const echarts = require('echarts')

// 按需引入
// 引入 ECharts 主模塊
var echarts = require('echarts/lib/echarts');
// 引入柱狀圖
require('echarts/lib/chart/bar');
// 引入提示框和標題組件
require('echarts/lib/component/tooltip');
require('echarts/lib/component/title');

3.使用

筆者搭配vue使用echarts,以下代碼均包含vue.js相關代碼。

3.1 初始化

echarts的初始化使用很簡單,引入的echarts主模塊里,可以調用init()初始話函數,給這個函數傳入dom元素,再根據自己需要進行設置setOption即可。

<div id='test'/>

需要注意的是,你需要提前給定這個元素的寬高,特別是高度height,否則在初始化成功之后你會看不到它。

3.2 數據獲取與更新

echarts支持異步加載和更新數據,同樣只需要將新的數據傳入setOption即可。

/**
  option: any = {
    title: {
      text: '柱狀圖'
    },
    tooltip: {},
    xAxis: {
      data: ['襯衫', '羊毛衫', '雪紡衫', '褲子', '高跟鞋', '襪子']
    },
    yAxis: {},
    series: [{
      name: '銷量',
      type: 'bar',
      data: []
    }]
  }
*/

    initEchatrs() {
    this.ele = echarts.init(document.getElementById('test'))
    this.ele.setOption(this.option)
    this.ele.showLoading()
    window.onresize = this.ele.resize  // 隨窗口尺寸變化調整自身尺寸

    this.getAsyncData()
  }

  getAsyncData() {
    setTimeout(() => {
      // 此處修改對象屬性值,有些類似於能讓watch監聽到的變化才能生效
      // 直接對this.option.xx進行增刪改是無法生效的,使用this.$set或者object.assign()
      this.$set(this.option, 'series', [{
        name: '銷量',
        type: 'bar',
        data: [5, 20, 36, 10, 10, 20]
      }])
      this.ele.setOption(this.option)
      this.ele.hideLoading()
    }, 1500)

3.3 事件和行為

echarts中綁定事件通過on方法

myChart.on('click', function (params) {
    // 控制台打印數據的名稱
    console.log(params.name);
});

echarts里的事件類型包括鼠標事件和使用可以交互的組件(如縮放數據區域等)后觸發的行為事件。

它支持常規的鼠標事件類型,包括click、dbclick、mousedown、mousemove、mouseup、mouseover、mouseout等,將對應的事件名傳入on方法即可,回調事件包含參數params,它包含一個點擊圖形的數據信息的對象

{
    // 當前點擊的圖形元素所屬的組件名稱,
    // 其值如 'series'、'markLine'、'markPoint'、'timeLine' 等。
    componentType: string,
    // 系列類型。值可能為:'line'、'bar'、'pie' 等。當 componentType 為 'series' 時有意義。
    seriesType: string,
    // 系列在傳入的 option.series 中的 index。當 componentType 為 'series' 時有意義。
    seriesIndex: number,
    // 系列名稱。當 componentType 為 'series' 時有意義。
    seriesName: string,
    // 數據名,類目名
    name: string,
    // 數據在傳入的 data 數組中的 index
    dataIndex: number,
    // 傳入的原始數據項
    data: Object,
    // sankey、graph 等圖表同時含有 nodeData 和 edgeData 兩種 data,
    // dataType 的值會是 'node' 或者 'edge',表示當前點擊在 node 還是 edge 上。
    // 其他大部分圖表中只有一種 data,dataType 無意義。
    dataType: string,
    // 傳入的數據值
    value: number|Array
    // 數據圖形的顏色。當 componentType 為 'series' 時有意義。
    color: string
}

on事件還支持query,query可以只對指定的圖形元素觸發回調。

chart.on(eventName, query, handler);

query可以為字符串

chart.on('click', 'series', function () {...});
chart.on('click', 'series.line', function () {...});
chart.on('click', 'dataZoom', function () {...});
chart.on('click', 'xAxis.category', function () {...});

也可以為對象object,它包含以下可選屬性

{
    <mainType>Index: number // 組件 index
    <mainType>Name: string // 組件 name
    <mainType>Id: string // 組件 id
    dataIndex: number // 數據項 index
    name: string // 數據項 name
    dataType: string // 數據項 type,如關系圖中的 'node', 'edge'
    element: string // 自定義系列中的 el 的 name
}

chart.setOption({
    // ...
    series: [{
        name: 'uuu'
        // ...
    }]
});
chart.on('mouseover', {seriesName: 'uuu'}, function () {
    // series name 為 'uuu' 的系列中的圖形元素被 'mouseover' 時,此方法被回調。
});

其他支持的組件交互行為都會觸發對應的事件,在官方文檔中均有說明

3.4 繪制svg

echarts最開始是使用canvas繪制圖表的,目前echarts 4.0以上已經支持svg繪制。

只需要在init中的參數中設置render參數即可。

// init: Function
(dom: HTMLDivElement|HTMLCanvasElement, theme?: Object|string, opts?: {
    devicePixelRatio?: number,
    renderer?: string,
    width?: number|string,
    height?: number|string
}) => ECharts

// dom: 實例的容器,一般是一個具有寬高的div元素
// theme: 應用主題
// opts: 附加參數,可選項有:
//                         devicePixelRatio: 設備像素比,默認取瀏覽器的window.devicePixelRatio
//                         renderer: 渲染器,可選'canvas'或者'svg'
//                         width: 實例寬度,px
//                         height: 實例高度,px

3.5 其他api

echarts的api文檔寫的十分詳盡,參考它的官方文檔,使用你想使用的組件即可。

4.canvas

canvas與svg都是瀏覽器端繪制圖形的手段,但是他們在根本上是不同的。僅在繪圖方面,svg的優勢在於不會失真,渲染性能略高(因此更適合移動端)、內存占用更低;canvas更適合繪制圖形元素數量非常大的圖表。

echarts的根本是對canvas/svg的操作,它的底層使用了zRender來繪制。

echarts的github:https://github.com/apache/incubator-echarts

這里僅討論canvas。

<canvas>標簽提供了一塊空白的畫布容器,它公開了一個或多個渲染上下文,需要通過腳本在上面繪制。使用原生的canvas繪制圖形,需要對canvas的api使用熟練。

canvas api:https://www.runoob.com/tags/ref-canvas.html

具體代碼可以參考后面,先記錄一下實際中踩到的幾個坑:

4.1 繪制1px線

參考:https://www.cnblogs.com/v-rockyli/p/3833845.html

下面兩張圖分別是處理前和處理后的效果

          處理后

          處理前

仔細觀察可以發現下圖坐標軸和對齊軸的線條,比起上圖看起來要粗一些、顏色淺一些。實際上兩張圖內繪制的線條都是1px。為什么會有這種區別呢?

這跟canvas的繪制邏輯有關,當我們試圖繪制一個線段時,canvas會讀取lineWidth,,然后嘗試將在坐標處兩邊各繪制一半的lineWidth。

 

比如我們想在坐標(0,3)處繪制一條橫線,canvas會以3為中軸線,在兩邊各畫0.5像素,深藍色就是我們期望的效果(2.5-3.5,1個像素),但實際上,淺藍色也會被繪制出來,因為canvas無法在整個像素寬內只繪制半個像素,所以坐標軸上下兩個方向都都會被擴展至整個像素寬度內(2-4,兩個像素),但是擴展的像素實際的值並不是原值相同,而是取其一半,所以最直接的視覺感受是:線條比預想的變寬了,但是顏色淺了很多。

還是以寬為1的橫線為例,我們如果將其繪制在縱坐標2.5處,即以半像素作為中軸線

 

同樣瀏覽器進行繪制時,在2.5上下各繪制0.5的像素寬度,但與上面的例子不同的是,圖像邊界正好落在整數像素邊界內,合起來正好為1個像素,這個時候,就不需要向兩邊擴展,而是我們預期的的1個像素寬度。

同理,我們分別使用兩種方式繪制寬度為2的線段時,效果恰恰相反,在坐標3處繪制的時候,像素正好擴展至2-4,即2個像素,符合我們的預期;而在坐標2.5處繪制時,像素擴展至1.5-3.5,未到邊界,需要補足,就變成了1-4,即3個像素。

因此在實際應用中,如果想得到更好的體驗,精確的像素值,如果線段的寬度是奇數像素,繪制時以n.5,即半數像素作為中軸線,如果線段的寬度為偶數像素,繪制時以n.0,即整數像素作為中軸線

4.2 視口與畫布

canvas與svg一樣 存在視口的概念,不指定canvas標簽的寬高屬性時, 默認為300*150。如果在內部樣式中指定canvas畫布的大小,而不指定視口大小,在實際中會影響到繪制效果。
我們指定畫布大小為300*300,下圖是視口寬度300時,分別指定視口高度150與300的效果

        視口高度150

        視口高度300

可以明顯看出效果會按照視口與畫布的尺寸進行等比縮放;當視口高度與畫布高度1:1的時候,看起來效果是最好。因此指定畫布大小的時候,最好也指定canvas視口寬高。

<canvas id="canvas" :width="width" :height="height"></canvas>

 width = 300
 height = 300

#canvas {
  width: 300px;
  height: 300px;
} 

但是我們觀察echarts的視口與畫布大小,可以看出echarts默認視口:畫布=2:1,這樣做也是有道理的,視口是畫布的2倍,可以在縮放到200%的情況下不失真。

 因此如果只是想簡單實現一個表格,不需要考慮縮放的效果的話,直接指定視口:畫布=1:1即可(移動端大多數可以如此);如果需要考慮縮放n倍不失真(比如pc端瀏覽器的縮放功能),就需要增加視口寬高。

4.3 繪制path

canvas的繪制方法中比較重要的一個手段就是繪制path,其中比較值得注意的是beginPath和closePath。

其中個人認為beginPath要比closePath要重要。從api的解釋來看,beginPath有“重置當前路徑”的功能,closePath只是創建一條新的路徑,幫忙把這個圖形閉合了而已。

實際操作中不注意begin和close會引起的問題:

      圖1 期望效果

      圖2 無closePath

      圖3 無beginPath

這里圖1的繪制流程可以查看下面代碼

  getAsyncData() {
    setTimeout(() => {
      this.yAxis = [5, 20, 36, 10, 10, 20]
      // y坐標軸
      // 計算刻度
      const num = Math.ceil((Math.max(...this.yAxis)) / this.minUnit)
      const yGap = this.yAxisLen / num
      const max = num * this.minUnit

      // 繪制刻度及對齊軸
      for(let i = 0; i < num; i++) {
        const text = this.minUnit * (i + 1) + ''
        // 文本
        this.context.fillText(text, this.x0 - text.length * this.fontsize / 1.5, this.height - (10 + yGap * (i + 1) + this.fontsize / 2))
        // 刻度
        this.context.beginPath()
        const yUnit = Math.floor(this.y0 - this.yAxisLen + yGap * i) + 0.5
        this.context.moveTo(this.x0, yUnit)
        this.context.lineTo(this.x0 - 3, yUnit)
        this.context.strokeStyle = '#000000'
        this.context.stroke()
        // 對齊軸
        this.context.beginPath() this.context.moveTo(this.x0, yUnit)
        this.context.lineTo(this.width, yUnit)
        this.context.strokeStyle = '#eeeeee'
        this.context.stroke()
      }

      // 繪制條狀圖
      const xGap = (this.width - this.x0) / this.xAxis.length
      this.context.fillStyle = this.barColor
      this.barList = []
      this.start = new Array(this.yAxis.length).fill(0)   // 初始化幀數計數數組
      this.yAxis.map((data, index) => {
        const x1 = this.x0 + xGap * (index + 0.5) - this.barWidth / 2 
        const h = data / this.minUnit * yGap 
        const y1 = this.y0 - h
        const barData = {x: x1, y: y1, w: this.barWidth, h}
        // 保存色塊數據
        this.barList.splice(index, 0, Object.assign({}, barData, {
          data,
          index,
          name: this.xAxis[index],
          color: this.barColor
        }))
        // 通過path繪制矩形
        // this.drawRectByPath(x1, y1, this.barWidth, h)
        // 添加動畫效果
        this.animate(barData, index)
      })
    }, 1500);
  }

  drawRectByPath(x: number, y: number, w: number, h: number) {
    // 通過路徑繪制矩形,x,y為左上角,w為寬,h為高
    // 如果不重置路徑(beginPath)那么會從上一次的beginPath開始執行
    this.context.beginPath() this.context.moveTo(x, y)
    this.context.lineTo(x + w, y)
    this.context.lineTo(x + w, y + h)
    this.context.lineTo(x, y + h)
    // 如果不closePath,該路徑則無法閉合,只會影響描邊,不影響填充
    this.context.closePath()
    this.context.strokeStyle = 'rgba(0, 0, 0, 0.1)'
    this.context.fillStyle = 'rgba(200, 0, 0, 0.1)'
    // 未閉合路徑也會自動回到開始路徑並填充,但是stroke不會
    this.context.stroke()
    this.context.fill()
  }

簡單來說是,從上到下繪制刻度軸,從左到右依次繪制條狀,path的路徑是從左上角開始順時針連線,顏色是有透明度的。

圖1是正常效果,圖2是繪制條狀時未執行closePath,圖3是繪制條狀時未執行beginPath。

圖2中不執行closePath,可以看出是不影響fill的,對非閉合圖形執行fill,會幫你把圖形閉合(從最后一點回到起點的一條路徑),然后再填充。此處起點為上一次moveTo所在的點。對於stroke,繪制了多少線條就會描邊多少線條,並不會自動閉合。

圖3中不執行beginPath的影響就大多了,存在兩個問題:

1.最左邊的條狀顏色偏深,越往右越淺

2.最下面的刻度軸壓住了除了最右的條狀的所有條狀(圖上看不太出來,因為最右的顏色比較淺)

為什么會這樣呢?

自然我們會從beginPath開始入手,beginPath的作用是重置當前路徑,如果沒有重置路徑,那么這次的繪制是從哪里開始的呢?

答案是從上次beginPath之后重新執行。

如果繪制條狀的時候沒有beginPath,那么繪制過程就會如下:

繪制起點是上一次beginPath的時候,也就是最后一條刻度軸的起點(綠色點)。

繪制第n條的過程是這樣的:

1.從綠色點作為第一次起點,繪制一次刻度軸

2.紅色點作為第二次起點,繪制一次第一條條狀

3.依次繪制第二條、第三條,直到第n條

因此繪制第n條的時候,第一條已經被重復繪制、填充了n次,第二條n-1次,...,以此類推。

所以繪制完成6條,坐標軸被重復繪制了7次,第7次繪制時,蓋住了前面繪制的條狀,被后續繪制的條狀蓋住;第一條被重繪了6次,顏色疊加,因此顏色最深;第六條只繪制了1次,顏色最淺

因此使用path的時候一定要注意使用beginPath和closePath。

4.3 實現動畫效果

參考:https://m.html.cn/web/javascript/12369.html

實現動畫效果實際上是對部分區域的清除和重繪,每秒重繪24幀即可讓人看起來這個動作是連續的,因此每秒能實現24幀及以上的重繪,即可實現動畫效果。

原理我們知道了,但是如何做到每秒刷新24次及以上,還需要一個函數的支持:window.requestAnimationFrame

對此api的解釋可以查看mdn:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame

  animate(to: any, index: number) {
    // 計算此時的y, h
    const h = Math.floor(to.h / this.frames * this.start[index]) + 0.5
    const y = this.y0 - h
    // 清除上一次繪制的矩形
    this.context.clearRect(to.x, y, to.w, h)
    // 重繪矩形
    this.context.fillRect(to.x, y, to.w, h)
    // 變化
    this.start[index]++
    if (this.start[index] <= this.frames) {
      window.requestAnimationFrame(() => this.animate(to, index))
    }
  }

4.4 事件綁定

在開始的canvas與svg的區別中,w3school列出了他們的差異之處,其中提到了canvas是不支持事件處理器的,canvas繪制完成后就成為一整塊畫布,不再引起瀏覽器的注意,我們無法對其中某個色塊或者線段進行操作。

因此如果想給某個色塊添加事件,需要通過坐標的方式判斷點擊的是哪個色塊。

  bindEvents() {
    // 給整個canvas綁定事件
    const canvas = document.getElementById('canvas') as HTMLCanvasElement
    // mousedown
    canvas.addEventListener('mousedown', this.mouseDownEvent.bind(this))
  }

  mouseDownEvent(e: any) {
    // 判斷坐標
    const {offsetX, offsetY} = e
    // 對所有色塊遍歷,判斷點擊了哪個色塊
    this.barList.map((bar, index) => {
      if (this.checkBoundary(offsetX, offsetY, bar.x, bar.y, bar.w, bar.h)) {
        console.log('you click this bar', bar)
      }
    })
  }

  checkBoundary(x0: number, y0: number, x1: number, y1: number, w: number, h: number) {
    // x0, y0為點擊的坐標,x1,y1為色塊左上角坐標,w,h為色塊寬高
    return x0 > x1 && x0 < (x1 + w) && y0 > y1 && y0 < (y1 + h)
  }

如果你的canvas繪制內容比較復雜,還需要考慮色塊重疊時的情況。如果還需要做元素的拖拽、縮放、旋轉、刪除等,可能會更復雜。個人感覺對canvas的事件綁定遠不如svg的簡單。

---------------------end--------------------

全部代碼(vue+ts):

<template>
  <div class="container">
    <span class="zero">0</span>
    <span class="x">x</span>
    <span class="y">y</span>
    <canvas id="canvas" :width="width" :height="height"></canvas>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'

@Component
export default class Canvas extends Vue {
  // 視口
  width = 300
  height = 300
  // 字體大小
  fontsize = 14
  // 坐標軸
  minUnit = 10  // y軸最小刻度
  barWidth = 30 // 條狀圖寬度
  barList: any[] = [] // 條狀圖色塊數據,坐標、寬、高
  barColor = 'rgb(200, 0, 0)'
  xAxis = ['襯衫', '羊毛衫', '雪紡衫', '褲子', '高跟鞋', '襪子']    // x軸數據
  yAxis: number[] = []    // y軸數據
  // 動畫
  start: number[] = []
  frames = 30

  // canvas上下文
  context: any

  get x0() {
    // 需要注意canvas的繪制問題
    // canvas繪制時會讀取lineWidth,然后嘗試在坐標處兩邊各繪制一般的lineWidth
    // 當lineWidth=1時,canvas會嘗試在整數坐標處左右各繪制半個像素
    // 因為canvas無法在一個像素內繪制半個像素,因此坐標處上下兩個方向都會被擴展至整個像素寬度內,即兩個像素
    // 導致坐標處繪制1像素線條和2像素線條看起來時一樣的 只是1像素線條顏色淺了一些
    // 解決辦法是在想在n繪制1像素線條時,最好是n.5處繪制
    return 25.5  
  }

  get y0() {
    return this.height - 20.5
  }

  get xAxisLen() {
    return this.width - this.x0
  }

  get yAxisLen() {
    return this.y0 - 10.5
  }

  mounted() {
    // canvas創造了一個固定大小的畫布,它公開了一個或多個渲染上下文
    // canvas與svg一樣 存在視口的概念,不指定canvas標簽的寬高屬性時, 默認為300*150
    // 在內部樣式中指定canvas畫布的大小,而不指定視口大小,在實際中會影響到繪制效果
    const canvas: HTMLCanvasElement = document.getElementById('canvas') as HTMLCanvasElement
    // 獲取2d上下文,沒有這個方法則表示可能不支持canvas
    this.context =  canvas.getContext('2d')

    // 繪制輔助坐標軸
    this.drawSupport()

    // 繪制圖表
    this.draw()
  }

  drawSupport() {
    // 開始繪制一段路徑,並指定這段路徑的樣式
    this.context.beginPath()    // 起始一段路徑,或重置當前路徑
    this.context.moveTo(this.width, 0)
    this.context.lineTo(0, 0)
    this.context.lineTo(0, this.height)
    // this.context.closePath()
    this.context.strokeStyle = "#ff0000"
    this.context.stroke()
  }

  draw() {
    // 繪制坐標軸
    this.context.beginPath()
    this.context.moveTo(this.x0, this.y0 - this.yAxisLen)
    this.context.lineTo(this.x0, this.y0)
    this.context.lineTo(this.width, this.y0)
    // this.context.closePath()
    this.context.strokeStyle = '#000000'
    this.context.stroke()

    // x坐標軸
    this.context.font = `${this.fontsize}px Arial`
    const xGap = (this.width - this.x0) / this.xAxis.length
    this.xAxis.map((text, index) => {
      // 文本
      this.context.fillText(text, this.x0 + xGap * (index + 0.5) - text.length * this.fontsize / 2, this.height - 5)
      // 刻度
      this.context.beginPath()
      this.context.moveTo(this.x0 + xGap * (index + 1), this.y0)
      this.context.lineTo(this.x0 + xGap * (index + 1), this.y0 - 3)
      this.context.closePath()
      this.context.stroke()
    })

    // 模擬異步獲取數據
    this.getAsyncData()

    // 綁定事件
    this.bindEvents()
  }

  getAsyncData() {
    setTimeout(() => {
      this.yAxis = [5, 20, 36, 10, 10, 20]
      // y坐標軸
      // 計算刻度
      const num = Math.ceil((Math.max(...this.yAxis)) / this.minUnit)
      const yGap = this.yAxisLen / num
      const max = num * this.minUnit

      // 繪制刻度及對齊軸
      for(let i = 0; i < num; i++) {
        const text = this.minUnit * (i + 1) + ''
        // 文本
        this.context.fillText(text, this.x0 - text.length * this.fontsize / 1.5, this.height - (10 + yGap * (i + 1) + this.fontsize / 2))
        // 刻度
        this.context.beginPath()
        const yUnit = Math.floor(this.y0 - this.yAxisLen + yGap * i) + 0.5
        this.context.moveTo(this.x0, yUnit)
        this.context.lineTo(this.x0 - 3, yUnit)
        this.context.strokeStyle = '#000000'
        this.context.stroke()
        // 對齊軸
        this.context.beginPath()
        this.context.moveTo(this.x0, yUnit)
        this.context.lineTo(this.width, yUnit)
        this.context.strokeStyle = '#eeeeee'
        this.context.stroke()
      }

      // 繪制條狀圖
      const xGap = (this.width - this.x0) / this.xAxis.length
      this.context.fillStyle = this.barColor
      this.barList = []
      this.start = new Array(this.yAxis.length).fill(0)   // 初始化幀數計數數組
      this.yAxis.map((data, index) => {
        const x1 = this.x0 + xGap * (index + 0.5) - this.barWidth / 2 
        const h = data / this.minUnit * yGap 
        const y1 = this.y0 - h
        const barData = {x: x1, y: y1, w: this.barWidth, h}
        // 保存色塊數據
        this.barList.splice(index, 0, Object.assign({}, barData, {
          data,
          index,
          name: this.xAxis[index],
          color: this.barColor
        }))
        // 通過path繪制矩形
        // this.drawRectByPath(x1, y1, this.barWidth, h)
        // 添加動畫效果
        this.animate(barData, index)
      })
    }, 1500);
  }

  animate(to: any, index: number) {
    // 計算此時的y, h
    const h = Math.floor(to.h / this.frames * this.start[index]) + 0.5
    const y = this.y0 - h
    // 清除上一次繪制的矩形
    this.context.clearRect(to.x, y, to.w, h)
    // 重繪矩形
    this.context.fillRect(to.x, y, to.w, h)
    // 變化
    this.start[index]++
    if (this.start[index] <= this.frames) {
      window.requestAnimationFrame(() => this.animate(to, index))
    }
  }

  drawRectByPath(x: number, y: number, w: number, h: number) {
    // 通過路徑繪制矩形,x,y為左上角,w為寬,h為高
    // 如果不重置路徑(beginPath)那么會從上一次的beginPath開始執行
    this.context.beginPath()
    this.context.moveTo(x, y)
    this.context.lineTo(x + w, y)
    this.context.lineTo(x + w, y + h)
    this.context.lineTo(x, y + h)
    // 如果不closePath,該路徑則無法閉合,只會影響描邊,不影響填充
    this.context.closePath()
    this.context.strokeStyle = 'rgba(0, 0, 0, 0.1)'
    this.context.fillStyle = 'rgba(200, 0, 0, 0.1)'
    // 未閉合路徑也會自動回到開始路徑並填充,但是stroke不會
    this.context.stroke()
    this.context.fill()
  }

  bindEvents() {
    // 給整個canvas綁定事件
    const canvas = document.getElementById('canvas') as HTMLCanvasElement
    // mousedown
    canvas.addEventListener('mousedown', this.mouseDownEvent.bind(this))
  }

  mouseDownEvent(e: any) {
    // 判斷坐標
    const {offsetX, offsetY} = e
    // 對所有色塊遍歷,判斷點擊了哪個色塊
    this.barList.map((bar, index) => {
      // todo: 如果圖形有重疊,點擊重疊區域,如何選中上層元素
      // 引入“層級”概念,為每個圖形添加層級屬性。越新創建的圖形層級越高。
      // 點擊重疊區域,直接選中層級高的元素
      // 點擊非重疊的圖形區域,將該圖形的層級提到最高
      if (this.checkBoundary(offsetX, offsetY, bar.x, bar.y, bar.w, bar.h)) {
        console.log('you click this bar', bar)
      }
    })
  }

  checkBoundary(x0: number, y0: number, x1: number, y1: number, w: number, h: number) {
    // x0, y0為點擊的坐標,x1,y1為色塊左上角坐標,w,h為色塊寬高
    return x0 > x1 && x0 < (x1 + w) && y0 > y1 && y0 < (y1 + h)
  }
}
</script>

<style scoped>
.container {
  position: relative;
}
.container span {
  position: absolute;
  color: red;
}
.zero {
  top: -10px;
  left: 15px;
}
.x {
  top: -10px;
  right: 10px;
}
.y {
  top: 100%;
  left: 25px;
}

#canvas {
  width: 300px;
  height: 300px;
}
</style>

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM