K 线图高性能窗口化渲染

项目仓库:

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 获取。

布局

html 复制代码
<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。

节流限制

js 复制代码
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 Wg K 线间隙宽度 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,⌊USleft⌋−1)
    注意这里要 -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 坐标系
javascript 复制代码
/* 重置变换并缩放 */
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 线:

javascript 复制代码
/* 画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 逻辑像素,放大画布的同时放大坐标系:

javascript 复制代码
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 宽度的影线。

相关推荐
_AaronWong14 小时前
Electron 实现仿豆包划词取词功能:从 AI 生成到落地踩坑记
前端·javascript·vue.js
cxxcode14 小时前
I/O 多路复用:从浏览器到 Linux 内核
前端
用户54330814419414 小时前
AI 时代,前端逆向的门槛已经低到离谱 — 以 Upwork 为例
前端
JarvanMo14 小时前
Flutter 版本的 material_ui 已经上架 pub.dev 啦!快来抢先体验吧。
前端
JohnYan14 小时前
工作笔记-CodeBuddy应用探索
javascript·ai编程·aiops
恋猫de小郭15 小时前
AI 可以让 WIFI 实现监控室内人体位置和姿态,无需摄像头?
前端·人工智能·ai编程
哀木15 小时前
给自己整一个 claude code,解锁编程新姿势
前端
程序员鱼皮15 小时前
GitHub 关注突破 2w,我总结了 10 个涨星涨粉技巧!
前端·后端·github
UrbanJazzerati15 小时前
Vue3 父子组件通信完全指南
前端·面试
是一碗螺丝粉15 小时前
5分钟上手LangChain.js:用DeepSeek给你的App加上AI能力
前端·人工智能·langchain