K 线图高性能窗口化渲染

2025博客之星年度评选已开启 10w+人浏览 3.4k人参与

项目仓库:
Gitee:https://gitee.com/yyt363045841/klinechart
Github:https://github.com/363045841/klinechart
NPM:https://www.npmjs.com/package/@363045841yyt/klinechart
安装:npm i @363045841yyt/klinechart
项目持续更新中!欢迎提出宝贵建议!如果对您有帮助可以给个 Star!

本项目采用 Canvas 绘制 K 线图,行情数据通过 AKTools 获取。

布局

<template>
  <div class="chart-container" ref="containerRef" @scroll.passive="scheduleRender">
    <div class="scroll-content" :style="{ width: totalWidth + 'px' }">
      <canvas class="chart-canvas" ref="canvasRef"></canvas>
    </div>
  </div>
</template>

窗口化渲染的关键在于,每次不重绘所有的 K 线,而是计算出当前可视范围内的所有 K 线,每次仅重绘这部分区域。这就带来一个问题,Canvas 绘制区域不像以往绘制整片区域一样,可以撑开 chart-container 的宽度,使之可以左右滚动。
因此替代方案是创建一个宽度为计算出的所有 K 线宽度的总和的一个 scroll-content div,用来撑开容器。Canvas 正常在窗口区域绘制区域 K 线数据,而不是每次绘制全部区域。

区域绘制

外层 chart-container 容器响应 scroll 事件,触发区域重绘节流函数 scheduleRender。

节流限制

let rafId: number | null = null

function scheduleRender() {
  if (rafId !== null) cancelAnimationFrame(rafId)
  rafId = requestAnimationFrame(() => {
    rafId = null
    render()
  })
}

由于用户一次滚动会触发多次滚动事件,不节流的话会产生大量重绘开销。节流函数会取消掉之前的排队渲染帧,因为已经过时,没必要再渲染。
requestAnimationFrame 调用后会返回一个 rafId,标记当前的渲染帧,这里渲染前置 null 是为了避免当前渲染帧被取消导致渲染不完全。

渲染计算

在这里插入图片描述
拿到外层 container 的 ref 引用,然后通过 getBoundingClientRect() 拿到视口宽高,进而动态设置 Canvas 宽高为视口区域,避免全部重绘。
接下来确定重绘哪些 K 线和对应的重绘坐标
基础数据:

符号含义对应代码
W v i e w W_{view} Wview视口宽度(屏幕能看到的宽度)viewWidth
S l e f t S_{left} Sleft当前滚动距离( scrollLeft )scrollLeft
W k W_k Wk单根 K 线宽度kWidth
W g W_g WgK 线间隙宽度kGap
N t o t a l N_{total} Ntotal数据总数量n
U U U单元宽度(1 根 K 线 + 1 个间隙)unit
  1. 通过 container 的 scrollLeft DOM 属性拿到水平移动距离
  2. 确定渲染 K 线范围:
    单根 K 线宽度: U = W k + W g U = W_k + W_g U=Wk+Wg,那么渲染起始区域为
    S t a r t = max ⁡ ( 0 , ⌊ S l e f t U ⌋ − 1 ) Start = \max(0, \lfloor \frac{S_{left}}{U} \rfloor - 1) Start=max(0,USleft1)
    注意这里要 -1,为左侧区域提供缓冲区,避免边缘闪烁。
    终止区域为:
    E n d = min ⁡ ( N t o t a l , ⌈ S l e f t + W v i e w U ⌉ + 1 ) End = \min(N_{total}, \lceil \frac{S_{left} + W_{view}}{U} \rceil + 1) End=min(Ntotal,USleft+Wview+1)
    +1 同理。
  3. 移动 Canvas 坐标系
/* 重置变换并缩放 */
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, viewWidth, height) // 擦除上一帧

/* 保存状态,平移坐标系 */
ctx.save() // 压入状态栈
ctx.translate(-scrollLeft, 0)

/* 恢复状态 */
ctx.restore() // 将栈顶状态覆盖当前状态

首先,要理解 Canvas 绘图的底层机制:变换矩阵,每一次对 ctx 上下文的操作都是基于上一个变换结果的,为了确保每次都是从原来的坐标原点变换,每次做坐标变换之后都要重置回当前状态,否则会导致累积变换导致绘制错误。
将整个 Canvas 坐标系向左移动 scrollLeft 距离
( 0 , s c r o l l L e f t ) ← ( 0 , 0 ) (0,scrollLeft )\leftarrow(0,0) (0,scrollLeft)(0,0)
此时 Canvas 坐标系原点就和滚动 DOM 左上角对齐,统一了 DOM 内部坐标和 Canvas 坐标系,实现了坐标系对齐
接下来调用封装好的绘制函数绘制 K 线和 MA 线:

/* 画K线 */
  kLineDraw(ctx, kdata, opt, height, dpr, start, end, priceRange)

  /* 画MA */
  if (props.showMA.ma5) {
    drawMA5Line(ctx, kdata, opt, height, dpr, start, end, priceRange)
  }
  if (props.showMA.ma10) {
    drawMA10Line(ctx, kdata, opt, height, dpr, start, end, priceRange)
  }
  if (props.showMA.ma20) {
    drawMA20Line(ctx, kdata, opt, height, dpr, start, end, priceRange)
  }

Canvas 高清适配

核心在于联系 CSS 逻辑像素屏幕物理像素
在这里插入图片描述
先了解为什么不特殊处理会导致 Canvas 绘制模糊:
在右边 dpr 为 2 的屏幕中,屏幕实际上有 640 px,但是 Canvas 内部的逻辑像素只分配了 320 个像素,那么浏览器就必须将 320 px 拉伸到 640 px 上显示,此时边缘被线性插值来平滑过渡,导致边缘看起来模糊。
解决方法:
分配和屏幕像素相等的 CSS 逻辑像素,放大画布的同时放大坐标系:

canvas.width = Math.round(viewWidth * dpr)
canvas.height = Math.round(height * dpr)

ctx.scale(dpr, dpr) // x,y 轴的绘制宽度和高度都乘以 dpr

相当于抵消了放大 CSS 逻辑像素带来的影响,实际上就是 CSS 逻辑像素和屏幕像素的对齐
当然,如果现在要画 K 线影线,我们以屏幕像素为结果导向,要画一个 2 px 宽度的影线,但是如果画 2 px CSS 逻辑像素,就会画出实际 4 px 的影线,因此 CSS 绘制实际想要的屏幕像素,需要绘制除以 dpr 的宽度,即 2 d p r = 1    C S S    p x \frac{2}{dpr}=1\; CSS \; px dpr2=1CSSpx 宽度的影线。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yyt363045841

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值