前端可视化入门与实战 - 谦宇 - 掘金小册
上文示例是 ECharts 官网的示例 K 线图,在我们平常金融图表的开发中一般不会使用 ECharts,我们会更偏向更专业的金融图表库 TradingView。
但为了带领大家快速实现 K 线图,我们将用 Canvas 来实现一个 ECharts 基础示例。该图表的核心难点在于区域动态渲染和交互逻辑。
为避免信息传递误差,在绘制之前我们得先来聊一聊 K 线图中相关的概念。
了解 K 线图
K 线图是一种用于展示金融市场价格走势的图表形式,常用于股票、期货、外汇等市场的技术分析。它由一根根垂直的线段和横向的矩形构成,如下图所示:
K 线图中的每根 K 线代表一段时间内的价格走势,通常是一天或一周。每根 K 线由四个价格点组成,分别是开盘价(Open)、最高价(High)、最低价(Low)和收盘价(Close)。这四个价格点决定了 K 线的形状和颜色。
K 线的主体部分是开盘价和收盘价之间的矩形,用来表示价格的涨跌情况。如果收盘价高于开盘价,矩形通常会被填充为红色,表示价格上涨;如果收盘价低于开盘价,矩形通常会被填充为绿色,表示价格下跌。
K 线的上影线和下影线分别表示最高价和最低价与开盘价或收盘价之间的差异。上影线延伸至最高价,下影线延伸至最低价。
K 线图通过展示不同时间段的 K 线,可以帮助分析者观察价格的趋势和变化,以及判断市场的买卖力量和市场情绪。
在 K 线图中,K 线下面的柱状图被称为“成交量柱”,代表了相应时间段内的交易量,通常以垂直的柱状图形式显示在 K 线图的下方。如下图:
成交量是指在特定时间段内进行的交易数量或股票交易的总量。成交量柱的高度表示该时间段内的成交量大小,通常使用柱状图的高度来表示交易量的相对大小。
均线(Moving Average)则是一种技术分析工具,用于平滑价格数据并显示价格趋势的指标。它是通过计算一段时间内的价格平均值来绘制出来的。如下图:
常见的均线类型包括简单移动平均线(SMA)和指数移动平均线(EMA)。
简单移动平均线是将一段时间内的价格相加,然后除以时间段的长度来计算平均值。指数移动平均线则更加重视最近的价格数据,给予它们更高的权重。均线可以用于识别价格趋势、确定支撑和阻力水平、确认买入和卖出信号等。常见的均线周期包括 10 日均线、20 日均线、50 日均线和 200 日均线,不同周期的均线可以显示不同时间范围内的价格趋势。
使用较短期的均线(如 5MA)我们一般会采用较浅的颜色,而较长期的均线(如 20MA、50MA)则采用较深的颜色。
绘制 K 线图
了解了以上这些背景知识就足够我们开始绘制基础的 K 线图,我们首先从 option 配置开始。
option 配置
首先我们对原有 ECharts 的配置做简化调整,然后通过 splitData
处理 K 线数据和 calculateMA
处理均线数据,最后暴露出 option 配置项。
js
复制代码
// Each item: open,close,lowest,highest const data0 = splitData([ ["2013/1/24", 2320.26, 2320.26, 2287.3, 2362.94], ["2013/1/25", 2300, 2291.3, 2218.26, 2308.38], ["2013/1/28", 2295.35, 2346.5, 2215.35, 2396.92], //... ]); // 转换数据 function splitData(rawData) { const categoryData = []; const values = []; for (var i = 0; i < rawData.length; i++) { categoryData.push(rawData[i].splice(0, 1)[0]); values.push(rawData[i]); } return { categoryData: categoryData, values: values }; } // 计算 MA 均线 function calculateMA(dayCount) { var result = []; for (var i = 0, len = data0.values.length; i < len; i++) { // 必须超过最小天数 if (i < dayCount) { result.push("-"); continue; } var sum = 0; for (var j = 0; j < dayCount; j++) { sum += +data0.values[i - j][1]; } result.push(sum / dayCount); } return result; } const option = { grid: { left: 40, right: 40, bottom: 15, top: 20 }, xAxis: { data: data0.categoryData, offset: 30 }, theme: { bgColor: "#171b26", bgLineColor: '#252834', textColor: '#aeb1ba', helpColor: '#fff', upColor: '#f23645', downColor: '#089981', }, yAxis: { offset: 30, }, area: { start: 30, end: 80, }, series: [ { name: "日K", data: data0.values, }, // 5 日均线 { name: "MA5", type: "line", data: calculateMA(5), smooth: true, lineStyle: { opacity: 0.5 }, }, // 10 日均线 { name: "MA10", type: "line", data: calculateMA(10), smooth: true, lineStyle: { opacity: 0.5 }, }, // 20 日均线 { name: "MA20", type: "line", data: calculateMA(20), smooth: true, lineStyle: { opacity: 0.5 }, }, // 30 日均线 { name: "MA30", type: "line", data: calculateMA(30), smooth: true, lineStyle: { opacity: 0.5, }, }, ], }; export { option }
KlineChart 类
js
复制代码
export default class KlineChart { /** 时间集合 */ times = [] /** k线图集合 */ kList = [] /** k线图渲染长度 */ kLen = 0 /** 是否是第一次渲染 */ firstInto = true //... constructor(option) { // 解析option,方便获取数据 this.option = option this.times = option.xAxis.data this.kList = option.series[0].data this.kLen = this.kList.length this.view.start = option.area.start this.view.end = option.area.end // 初始化 canvas this.init() // 渲染 this.render() } }
首先获取到 option 后,我们需要转换属性挂载到类上,方便我们后续获取数据,然后在 init 中我们只会执行一次做 canvas 上下文的获取和基础配置。
js
复制代码
init() { // 获取上下文 const canvas = document.getElementById('myCanvas'); this.canvas = canvas this.ctx = canvas.getContext("2d"); this.width = canvas.width this.height = canvas.height // dpr 适配 const dpr = window.devicePixelRatio || 1 canvas.width = canvas.width * dpr canvas.height = canvas.height * dpr // 原点设置为左下角 canvas.style.transform = 'scaleY(-1)' canvas.style.transform = `translate(${canvas.width})` }
这里重要的关注点在于:
dpr 适配做法:为了解决高清屏的尺寸模糊问题。原点设置为左下角:我们知道 canvas 默认是屏幕坐标系,也就是原点在左上角,右侧为 x 轴的正轴,下侧为 y 轴的正轴。但是我们正常直觉使用笛卡尔坐标系会更舒服,也就是左下角为原点,右侧为 x 轴的正轴,上侧为 y 轴的正轴。这实际就是镜像翻转。但同时镜像翻转会带来一个问题——文字倒置,在后面会有对应解决方法的讲解。render 渲染
js
复制代码
requestAnimation() { const _self = this const { ctx, firstInto, canvas } = this const { theme } = this.option ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = theme.bgColor; ctx.fillRect(0, 0, canvas.width, canvas.height); // 限制option数据 this.limitArea() // 计算视口数据 this.calcView() // 执行一次监听事件 if (firstInto) this.watchEvent() // 绘制 this.draw() requestAnimationFrame(this.requestAnimation.bind(_self)) } render() { this.requestAnimation() }
渲染我们会使用 requestAnimationFrame
来动态更新,每次帧渲染完后,都会 clearRect
清理画布。然后因为我们的图表是有动态范围的,所以我们在 limitArea
中处理 option 传递的 data 数据做分割,方便后期的滚动事件做缩放数据范围的处理。
js
复制代码
limitArea() { let { start, end } = this.view const start_id = Math.floor(start * this.kLen / 100) const end_id = Math.floor(end * this.kLen / 100) this.view.times = this.times.slice(start_id, end_id + 1) this.view.kList = this.kList.slice(start_id, end_id + 1) this.view.kLen = this.view.kList.length }
在 calcView
中我们会计算视口范围的辅助数据,比如视口四个点位的坐标、实体的宽度等,我们都统一挂载在 view 属性上。
js
复制代码
export default class KlineChart { view = { /** 时间集合 */ times: [], /** 过滤挤占时间集合 */ filterTimes: [], /** k线图集合 */ kList: [], /** y轴标签集合 */ yLabels: [], /** x轴刻度x坐标集合 */ xTicks: [], /** k线渲染个数 */ kLen: 0, /** k线区域坐标 */ lb: { x: 0, y: 0 }, rt: { x: 0, y: 0 }, rb: { x: 0, y: 0 }, lt: { x: 0, y: 0 }, /** k线区域尺寸 */ width: 0, height: 0, /** 实心宽度 */ solidWidth: 0, /** 绘图区域Y轴的val范围 */ yMaxVal: 0, yMinVal: 0, yAreaVal: 0, /** 安全区域Y轴的val范围 */ yMaxSafeVal: 0, yMinSafeVal: 0, /** 范围id */ start: 0, end: 0, } //... }
calcView 这里我们讲解下需要哪些辅助数据。
首先在做绘制图元的时候坐标的计算都是包含安全区外的尺寸,也就是 grid 的 left、right、top、bottom,这对于我们维护图表是很麻烦的。所以,我们需要提前计算出下图红圈的坐标点,得到这些坐标点后我们就知道 K 线图区域的宽和高,对于后期计算 x 和 y 轴的 label 都是有好处的。
其次,在 x 轴的类目值我们没有必要做缩放,但是在 Y 轴我们的值是随时变化的,我们需要缩放值映射到 0 到 100 的范围。所以,有了 yMinVal、yMinSafeVal 这些的计算,至于 Safe 安全值主要用于计算 K 线的实际范围压缩在可控区域内。
然后计算 y 的 label 集合、K 线图实体宽度、x 轴刻度坐标等。
js
复制代码
calcView() { const { grid, xAxis } = this.option const { width, height } = this const distance = 20 const step = 5 let max_value = 0, min_value = Infinity // 计算视口坐标 this.view.lb = { x: grid.left, y: grid.bottom + xAxis.offset } this.view.rt = { x: width - grid.right, y: height - grid.top } this.view.rb = { x: width - grid.right, y: grid.bottom + xAxis.offset } this.view.lt = { x: grid.left, y: height - grid.top } this.view.width = this.view.rb.x - this.view.lb.x this.view.height = this.view.rt.y - this.view.rb.y // 计算 y 轴的范围值 this.view.kList.forEach(item => { max_value = Math.max(max_value, ...item) min_value = Math.min(min_value, ...item) }) this.view.yMaxSafeVal = max_value this.view.yMinSafeVal = min_value const min_integer = Math.floor(min_value - min_value % 10) const max_integer = Math.floor(max_value + (10 - max_value % 10)) this.view.yMinVal = min_integer - distance this.view.yMaxVal = max_integer + distance this.view.yAreaVal = this.view.yMaxVal - this.view.yMinVal const size = Math.floor(this.view.yAreaVal / step) // 计算y的label集合 let yLabels = [this.view.yMinVal] let curY = this.view.yMinVal for (let i = 0; i < step; i++) { curY = curY + size yLabels.push(curY) } this.view.yLabels = yLabels // 计算实体宽度 this.view.solidWidth = +(this.view.width / (this.view.kLen * 2)).toFixed(2) // 计算 x 轴刻度坐标 const xDivide = this.view.width / (this.view.times.length - 1) let xTicks = [] this.view.times.forEach((item, index) => { xTicks.push(+(index * xDivide + this.view.lb.x).toFixed(2)) }) this.view.xTicks = xTicks }
draw 绘制
下面我们就开始 draw 绘制,不过在任何绘制前我们都需要封装一些基础的图元方法。
图元绘制封装如下:
js
复制代码
// 线段图元 export function drawLine(ctx, x, y, X, Y, color = '#fff') { ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(X, Y); ctx.stroke(); ctx.closePath(); }
draw 绘制如下:
js
复制代码
draw() { /** 绘制X轴 */ this.drawAxisX() /** 绘制Y轴 */ this.drawAxisY() /** 绘制X比例尺 */ this.drawScaleX() /** 绘制Y比例尺 */ this.drawScaleY() /** 绘制网格线 */ this.drawGrid() /** 绘制k线 */ this.drawK() /** 绘制辅助线 */ this.drawHelpLine() }
drawAxis 绘制轴
有了之前的 view 数据,接下来的绘制就会方便很多,我们首先看绘制轴:
js
复制代码
drawAxisX() { const { lb, rb } = this.view const { theme } = this.option drawLine(this.ctx, lb.x, lb.y, rb.x, rb.y, theme.bgLineColor) } drawAxisY() { const { lb, lt, rb } = this.view const { theme } = this.option drawLine(this.ctx, lb.x, lb.y, lt.x, lt.y, theme.bgLineColor) drawLine(this.ctx, rb.x, lb.y, rb.x, lt.y, theme.bgLineColor) }
在 drawAxisX 中,通过 lb 左下角的坐标点和 rb 右下角的坐标点我们直接用 drawLine 绘制即可。drawAxisY 亦是同理。
drawScaleX 绘制比例尺
绘制比例尺主要分为两个步骤:1. 绘制刻度;2. 绘制标注。
js
复制代码
drawScaleX() { const { ctx } = this const { xTicks, lb, filterTimes } = this.view const { theme } = this.option // 1.绘制刻度 // xTick: x 轴的刻度 x 坐标值的集合 xTicks.forEach(x => { ctx.beginPath() ctx.moveTo(x, lb.y) ctx.lineTo(x, lb.y - 10) ctx.stroke() }) ctx.save() // 垂直翻转 ctx.scale(1, -1) // 2. 绘制标注 xTicks.forEach((x, index) => { ctx.fillStyle = theme.textColor ctx.fillText(filterTimes[index], x - 25, -(lb.y - 20)) }) ctx.restore() } drawScaleY() { const { ctx } = this const { lb, height, yLabels } = this.view const { theme } = this.option const divide = height / (yLabels.length - 1) ctx.save() // 垂直翻转 ctx.scale(1, -1) yLabels.forEach((val, index) => { ctx.fillStyle = theme.textColor ctx.fillText(val, 10, -(lb.y + index * divide - 3)) }) ctx.restore() }
在绘制标注正常情况下会出现文本倒置的问题,如下:
js
复制代码
ctx.scale(1, -1) //... ctx.fillText(times[index], x - 20, -(lb.y - 20))
解决方式:scale
在 y 轴镜像后,同时对绘制文本的 view.lb.y
取反。
最后,为了翻转不影响之后的上下文绘制,所以在之前我们先 ctx.save 保存状态到栈顶,ctx.restore 恢复到存储在栈顶的状态。
为了方便后续的开发,我们封装下绘制文本的方法:
js
复制代码
export function drawText(ctx, val, x, y, { color, fontSize }) { ctx.save() ctx.scale(1, -1) ctx.font = `${fontSize}px Arial`; ctx.fillStyle = color ctx.fillText(val, x, -y) ctx.restore() }
drawGrid 绘制网格线
js
复制代码
drawGrid() { const { lb, rb, yLabels } = this.view const { theme } = this.option const divide = this.height / (yLabels.length) yLabels.forEach((val, index) => { if (index) { const y = lb.y + index * divide drawLine(this.ctx, lb.x, y, rb.x, y, theme.bgLineColor) } }) }
最后绘制效果:
drawCandle 绘制蜡烛
js
复制代码
drawK() { let candleCenters = [] this.view.kList.forEach((item, index) => { const { center } = this.drawCandle(item, this.view.times[index]) candleCenters.push(center) }) this.view.candleCenters = candleCenters }
在 drawK 中,我们只要知道 item 的收盘价、开盘价、最大涨幅和跌幅以及时间,即可计算出坐标。在 drawCandle 中,主要做了 Y 轴和 X 轴的值映射。candleCenters
主要收集蜡烛实体的中心坐标。
js
复制代码
drawCandle(item, name) { const { ctx } = this const { theme } = this.option // 缩放后的 实心底部, 实心顶部,lowest,highest的y值 const solidBottom = Math.min(this.y_toPos(item[0]), this.y_toPos(item[1])) const solidTop = Math.max(this.y_toPos(item[0]), this.y_toPos(item[1])) const lowest = this.y_toPos(item[2]) const highest = this.y_toPos(item[3]) const h = Math.abs(solidTop - solidBottom) const w = this.view.solidWidth const half_w = w * .5 const half_h = h * .5 const isUp = item[1] > item[0] const color = isUp ? theme.upColor : theme.downColor // 实心区域中心点 const center = { x: this.x_toPos(name), y: solidBottom + half_h } // 绘制蜡烛图的上下影线 ctx.strokeStyle = color; ctx.beginPath(); ctx.moveTo(center.x, highest); ctx.lineTo(center.x, lowest); ctx.stroke(); // 绘制蜡烛图的实体部分 ctx.fillStyle = color ctx.fillRect(center.x - half_w, center.y - half_h, w, h) }
我们首先要计算得到实际的绘制坐标 solidBottom、solidTop、lowest、highest
,而得到这些关键的值我们需要做映射转换 y_toPos
。同理,中心点的 x 值需要 x_toPos
映射。
js
复制代码
// 数值转为y轴坐标 y_toPos(val) { const { height, yAreaVal, yMinSafeVal, yMaxSafeVal, yMinVal, yMaxVal, lb } = this.view const safeBottomH = ((yMinSafeVal - yMinVal) / yAreaVal) * height const safeTopH = ((yMaxVal - yMaxSafeVal) / yAreaVal) * height const valH = ((val - yMinSafeVal) / (yMaxSafeVal - yMinSafeVal)) * (height - safeBottomH - safeTopH) return +(lb.y + safeBottomH + valH).toFixed(2) } // x 数值转为x轴坐标 x_toPos(name) { let { times, width, kLen, lb } = this.view const idx = times.findIndex(item => item === name) const x_divide = width / (kLen - 1) return +(lb.x + x_divide * idx).toFixed(2) }
最后就是绘制部分了。我们知道 Canvas 绘制 rect 矩形都是左下角开始的,那么我们只要知道中心点的坐标就很容易绘制实体部分,而不用判断是阳线还是阴线然后再做对应的尺寸计算。
另外还有一个核心的点在于,我们需要较少绘制 API 的使用,“绘制上影线和下影线”看着像需要绘制两遍,但其实只要绘制贯穿线一次即可。同时注意实体要在贯穿线后绘制,以起到遮挡效果。
整体绘制效果如下:
现在我们已经成功了一大半了,接下来可以很方便添砖加瓦,比如绘制之前我们在 option 配置传入的 MA5、MA10、MA20、MA30 这些均线。
drawAvgLine 绘制均线
在绘制前,我们先封装绘制折线、曲线的图元方法:
js
复制代码
export function drawPolyline(ctx, points, color, lineWidth) { ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.moveTo(points[0][0], points[0][1]); for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i][0], points[i][1]); } ctx.stroke(); } export function drawCurve(ctx, points, color, lineWidth) { ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = lineWidth; // 绘制起始点 ctx.moveTo(points[0][0], points[0][1]); // 将每个折线点转换为曲线点 for (let i = 1; i < points.length - 1; i++) { const x = (points[i][0] + points[i + 1][0]) / 2; const y = (points[i][1] + points[i + 1][1]) / 2; ctx.quadraticCurveTo(points[i][0], points[i][1], x, y); } // 绘制最后一个点 ctx.lineTo(points[points.length - 1][0], points[points.length - 1][1]); ctx.stroke(); }
接下来我们只要控制好范围,将数据转为二维数组即可,就像下面的用法一样。
js
复制代码
const canvas = document.getElementById('myCanvas'); const ctx = canvas.getContext('2d'); const points = [ [50, 50], [100, 150], [200, 100], [250, 200] ]; drawCurve(ctx, points, 'red', 2);
我们先限制范围:
js
复制代码
limitArea() { //... this.view.series = this.series.map(item => { return { ...item, data: item.data.slice(start_id, end_id + 1) } }) }
数据转换:
js
复制代码
calcView() { // 转换曲线 // ['-', '-', 11, 22, 33] -> [[x1, y1], [x2, y2], [x3, y3]] this.view.series.forEach(item => { let lines = [] const data = item.data data.forEach((x, j) => { const val = data[j] if (val !== '-') { lines.push([this.view.xTicksSum[j], this.y_toPos(val)]) } }) item.lines = lines }) }
开始绘制:
js
复制代码
drawAvgLine() { this.view.series.forEach(item => { drawCurve(this.ctx, item.lines, item.lineStyle.color, 1); }); }
最后效果:
事件处理
接下来是 K 线最核心的交互处理,比如辅助线定位、tooltip 浮层创建、滚动缩放、长按拖动等,这些的核心都在于浏览器的鼠标事件与 Canvas 的交互。下面我们将一步一步解决这些交互问题。
js
复制代码
export default class KlineChart { /** 事件相关数据 */ event = { /** 鼠标位置 */ pointer: { x: 0, y: 0 }, downPointer: { x: 0, y: 0 }, upPointer: { x: 0, y: 0 }, inner: false, activeId: -1, activeItem: null, } //... watchEvent() { this.firstInto = false window.addEventListener('mousemove', this.onMouseMove.bind(this)) window.addEventListener('mousedown', this.onMouseDown.bind(this)) window.addEventListener('mouseup', this.onMouseup.bind(this)) window.addEventListener('wheel', this.onWheel.bind(this)) } removeEvent() { window.removeEventListener('mousemove', this.onMouseMove.bind(this)) window.removeEventListener('mousedown', this.onMouseDown.bind(this)) window.removeEventListener('mouseup', this.onMouseup.bind(this)) window.removeEventListener('wheel', this.onWheel.bind(this)) } }
平移缩放
处理平移缩放,核心在于鼠标点击保存坐标,然后在 mousemove 中计算 x 坐标的差值,根据正负值得到方向,并做对应范围 start、end
限制。
js
复制代码
onMouseMove(e) { const { grid } = this.option const { clientX, clientY } = e const pos = this.canvas.getBoundingClientRect() const leftInner = clientX - pos.left - grid.left const topInner = clientY - pos.top - grid.top if (leftInner >= 0 && leftInner <= this.view.width && topInner >= 0 && topInner <= this.view.height) { this.event.pointer.x = leftInner this.event.pointer.y = this.height - topInner this.event.inner = true } else { this.event.inner = false console.log('超出区域'); } } onMouseDown(e) { this.event.downPointer.x = e.clientX this.event.downPointer.y = e.clientY } onMouseup(e) { this.event.upPointer.x = e.clientX this.event.upPointer.y = e.clientY console.log(e.clientX, e.clientY); const { upPointer, downPointer } = this.event if (Math.abs(upPointer.x - downPointer.x) > this.view.solidWidth) { if (upPointer.x < downPointer.x) { console.log('向左滑'); this.view.start -= 1 this.view.end -= 1 } else { console.log('向右滑'); this.view.start += 1 this.view.end += 1 } } }
滚动缩放
js
复制代码
onWheel(e) { const delta = Math.sign(event.deltaY); if (delta > 0) { console.log('放大数据'); this.view.start = Math.max(this.view.start - 1, 0) this.view.end = Math.min(this.view.end + 1, 100) } else if (delta < 0) { console.log('缩小数据'); if (this.view.start + 2 < this.view.end) { this.view.start += 1 this.view.end -= 1 } } }
其中,const delta = Math.sign(event.deltaY);
这行代码获取滚轮滚动的方向。使用 Math.sign()
函数可以将滚轮滚动的垂直方向速度转换为一个正负值,正值表示向上滚动(放大),负值表示向下滚动(缩小)。放大意味着缩小数据范围,注意 start 和 end 的取值范围。
来看看实现效果:
label 挤占问题
处理挤占问题,在于我们需要计算得到 label 间距是否小于遮挡最小限制值,然后不断递归,一旦超出限制就移除偶数位的刻度项。
js
复制代码
calcView() { // ... // 计算 x 轴刻度坐标 let xTicks = [] let filterTimes = this.view.times const xDivide = this.view.width / (this.view.times.length - 1) this.view.times.forEach((item, index) => { xTicks.push(+(index * xDivide + this.view.lb.x).toFixed(2)) }) // 兼容 x 轴挤占问题 const calcXTicks = (xTicks) => { let ticksLen = xTicks.length || this.view.times const textWidth = 50 let textDistance = (this.view.width - textWidth * ticksLen - textWidth) / (ticksLen - 1) if (textDistance < 2) { xTicks = removeOddIndexItems(xTicks) return calcXTicks(xTicks) } else { return xTicks } } xTicks = calcXTicks(xTicks) filterTimes = calcXTicks(filterTimes) this.view.xTicks = xTicks this.view.filterTimes = filterTimes }
实现效果如下(较上面动图,是不是有了明显变化):
根据鼠标位置绘制辅助线及 label
绘制辅助线是根据鼠标在 K 线图区域的具体位置来定位的,只会依附在最近的实体上,所以需要通过之前得到的实体的中心坐标,然后计算最小差值做固定辅助线。
我们先封装数值在一个数组中最小差值的计算:
js
复制代码
export function findClosestId(arr, value) { let minDiff = Math.abs(arr[0] - value); let closestId = 0; for (let i = 1; i < arr.length; i++) { let diff = Math.abs(arr[i] - value); if (diff < minDiff) { minDiff = diff; closestId = i; } } return { id: closestId, item: arr[closestId] }; }
开始绘制辅助线:
js
复制代码
drawHelpLine() { const { ctx } = this const { lb, lt, rt, candleCenters } = this.view const { pointer, inner } = this.event const { theme, grid } = this.option const xCandles = candleCenters.map(item => item.x) if (inner) { ctx.save() ctx.setLineDash([5, 5]); const X = pointer.x + grid.left const Y = pointer.y - grid.top // 最小差值计算 const { id, item } = findClosestId(xCandles, X) this.event.activeId = id // 绘制垂线 drawLine(this.ctx, item, lb.y, item, lt.y, theme.helpColor) // 绘制水平线 drawLine(this.ctx, lb.x, Y, rt.x, Y, theme.helpColor) ctx.restore() } }
实现效果如下:
我们继续!绘制辅助线的 label:
js
复制代码
drawHelpLine() { const { ctx } = this const { lb, lt, rt, rb, candleCenters, times } = this.view const { pointer, inner } = this.event const { theme, grid, xAxis } = this.option const xCandles = candleCenters.map(item => item.x) if (inner) { ctx.save() ctx.setLineDash([5, 5]); const X = pointer.x + grid.left const Y = pointer.y - grid.top // 临进计算 const { id, x } = findClosestId(xCandles, X) this.event.activeId = id // 计算实际源数据 this.event.activeOriginData = { y: this.view.kList[id], x: times[id] } // 计算展示 label 数据 const labelY = this.pos_toY(Y - grid.bottom - xAxis.offset).toFixed(1) // 绘制垂线 drawLine(this.ctx, x, lb.y, x, lt.y, theme.helpColor) // 绘制水平线 drawLine(this.ctx, lb.x, Y, rt.x, Y, theme.helpColor) ctx.restore() // 绘制数据label ctx.fillStyle = theme.textColor // 文本设置的尺寸一半 const helpLabelfontSizeHalf = theme.helpLabelfontSize * .5 const ylabelWidth = labelY.length * 5.3 const xlabelWidth = times[id].length * 5.3 // 绘制Y轴label ctx.fillRect(rb.x + helpLabelfontSizeHalf, Y - theme.helpLabelfontSize, ylabelWidth, 20) drawText(ctx, labelY, rb.x + helpLabelfontSizeHalf, Y - helpLabelfontSizeHalf + 2, { color: theme.bgLineColor, fontSize: theme.helpLabelfontSize }) // 绘制X轴label ctx.fillRect(x - xlabelWidth * .5, lb.y - 26, xlabelWidth, 20) drawText(ctx, times[id], x - xlabelWidth * .5, lb.y - 20, { color: theme.bgLineColor, fontSize: theme.helpLabelfontSize }) } }
在 drawHelpLine 我们有对应 x、y 轴的实际数值,所以我们在这里直接存储对应数据到 activeOriginData 中。其次我们需要将 y 坐标值转为实际数值,封装 pos_toY 方法,主要根据视口比例换算到实际数值。
js
复制代码
// y 轴坐标转为 y 数值 pos_toY(val) { const { yLabelDiff, yLabels } = this.view return (val / this.view.height) * yLabelDiff + yLabels[0] }
这下辅助线也有了自己的 label,如下:
更多交互实现
有了之前的基础,其他的交互实现会很容易,具体代码就不做演示了。
这里主要分享下实现思路:
tooltip 层:首先我们要封装通过坐标获取对应 x、y 轴的源数据,然后绘制顺序要放在 draw 方法的最后以实现最顶层循环渲染,并根据我们之前事件监听的 pointer 鼠标位置来定位 tooltip。 底部滑动杆的交互:核心还是在于范围的控制和鼠标事件的监听,只不过要注意双向的联动以及考虑性能优化,我们还是单独创建一个 canvas 上下文比较好。性能优化
如果实际项目中大体量的数据还是会有卡顿的问题,我们就可以从以下这几个方面优化。
Web Worker:将大批量数据的计算处理通过另外一个 js 线程计算。
离屏渲染:首先有一个主 canvas 和一个辅 canvas(不做展示层),辅 canvas 主要用于计算主 canvas 渲染后转成 img。当我们在视口渲染完后,会在辅 canvas 生成一块区域的 img,视口拖拽移动的时候,会不断更新区域并继续在辅 canvas 中拼接 img,一旦返回已经渲染过的,我们就可以在主 canvas 使用之前的 img 用 drawImage 绘制在界面上,此时当我们拖拽的时候,可以很方便使用 drawImage 来裁剪而不会计算过程。但是,如果在过程中存在大量的计算图形变化,那么这个方案反而不可取了。
动静分离、分层渲染:其实就是将固定不变的可以单独抽离到一个新的 canvas 中,防止频繁更新绘制。
总结
最后,我们总结下本节内容:
Canvas 我们需要知道一些小技巧,比如变换坐标轴、dpr 适配、如何解决文字倒置问题等。K 线图的核心难点在于范围筛选导致的图形变化,提前考虑并管理好数据,能极大方便我们后续的开发。K 线图的性能优化我们可以从 Web Worker、离屏渲染、分层渲染等考虑。下一节我们将通过 Konva 框架快速实现一个图形编辑器。