普通视图

发现新文章,点击刷新页面。
昨天 — 2025年10月14日首页

VChart 官网上线 智能助手与分享功能

作者 玄魂
2025年10月13日 20:27

1 🚀 VChart 官网全新分享功能上线,让你的图表“活”起来!

还在为如何分享和展示你的数据可视化作品而烦恼吗?现在,VChart 为您带来一系列强大的分享功能,让您的图表以前所未有的方式“活”起来,轻松嵌入任何应用场景!

该功能有助于您持久化图表案例,快速创建demo与他人进行分享讨论,也可以用来嵌入在其他web 应用中。


1.1 ✨ 分享功能入口

  1. 进入visactor.io/vchartvisactor.com/vchart)官网
  2. Playground页面任一图表Demo 页面
  3. 点击右上角的分享按钮

1.2 ✨ 核心功能说明

全新的分享功能全面支持 原生 JavaScript、 React 和 OpenInula 三大主流环境,确保您的图表Spec在任何环境都能完美呈现!

1.2.1 分享为 Playground:实时互动,即时调试

一键将您的图表配置分享为一个独立的 Playground 链接。您的同事或合作伙伴无需任何本地配置,即可在浏览器中直接查看、修改和调试图表,更可以用来和官方反馈问题,github 提issue时使用。

  • 跨环境支持:在原生、React 和 OpenInula 环境中无缝切换。
  • 配置继承:自动继承您当前使用的 VChart 版本和主题,保证环境一致性。
  • 效果演示:

2. 分享为 iframe:轻松嵌入,无缝集成

需要将图表嵌入到您的网站、博客或内部系统中?现在,只需复制一行 iframe 代码,即可将动态图表无缝集成到任何网页中。

  • 全环境覆盖:同样支持原生、React 和 OpenInula。
  • 版本与主题:自动同步 VChart 版本和主题,与您的应用风格保持一致。
  • 效果演示:
3. 分享为图片:一键截图,快速分享

需要将图表用于报告、演示文稿或社交媒体?全新的“分享为图片”功能,让您一键生成高清图表图片,随时随地分享您的数据洞察。

  • 简单快捷:在原生环境下,一键生成并下载图表图片。
  • 效果演示:

2 🚀 AI 智能助手,让图表编辑更“智能”

VChart 中的 AI 智能助手为图表编辑带来了更多便利与智能体验。一方面,其搜索框集成了 AI 助手,能帮助用户快速查找所需信息,大大提高信息检索效率,减少搜索时间成本 。另一方面,针对 AI 编辑功能,进行了 UI 优化,采用抽屉式交互,这种改进为用户营造了更流畅、更沉浸的编辑环境,减少外界干扰,让用户能够更专注于图表编辑。


2.1 ✨ 搜索框新增 AI 助手

VChart 的搜索框现已集成强大的 AI 助手,助您快速查找所需信息。

通过搜索框打开ai助手

输入问题获取答案:

2.2 ✨ AI 编辑功能 UI 优化

我们对 AI 编辑功能进行了 UI 优化,现已改为抽屉式交互,为您提供更流畅、更沉浸的编辑体验。

3 🚀 立即体验

访问 VChart 官网,立即体验吧!

这只是我们通过 AI 来提升用户体验的一小步,后面还有更多的大的动作,欢迎关注我们,进行交流和建议!

欢迎交流

最后,我们诚挚的欢迎所有对数据可视化感兴趣的朋友参与进来,参与 VisActor 的开源建设:

VChartVChart 官网VChart Github(欢迎 Star)

VTableVTable 官网VTable Github(欢迎 Star)

VMindVMind 官网VMind Github(欢迎 Star)

官方网站:www.visactor.io/www.viactor.com

Discord:discord.gg/3wPyxVyH6m

飞书群(外网):打开链接扫码

微信公众号:打开链接扫码

github:github.com/VisActor

昨天以前首页

用3Dmol.js展示3D分子结构

2025年10月12日 17:11

几个月前有个3D开发需求,展示化学分子结构,需求学科专业化程度较高了!

对方推荐了JSmol,但是这个跟Jmol(Java分子结构库)紧密结合。最终查找资料找到纯前端解决方案,决定用3Dmol.js实现。本文简单记录一下使用方法,免得忘了!

20250930_173221.gif

1.什么是3Dmol.js

image.png官方介绍:

3Dmol.js是一个基于WebGL的面向对象的JavaScript库,用于在线分子可视化 - 无需Java! 使用3Dmol.js,您可以将精美渲染的分子可视化添加到您的Web应用程序中。

特性

  • 支持pdb、sdf、mol2、xyz和cube格式
  • 并行分子表面计算
  • 球体、棒状、线条、十字、卡通和曲面样式
  • 基于原子属性的选择和样式设置
  • 标签
  • 与分子数据的可点击交互
  • 几何形状,包括球体和箭头

Github地址 https://github.com/3dmol/3Dmol.js

官网地址 https://3dmol.org/

2.使用3Dmol.js

2.1 安装

pnpm add 3dmol

该库基于typescript开发,友好支持typescript!

或者使用script形式引入

<script src="https://3Dmol.org/build/3Dmol-min.js"></script>

<script src="https://3Dmol.org/build/3Dmol.js"></script>

<script src="https://cdnjs.cloudflare.com/ajax/libs/3Dmol/2.0.1/3Dmol-min.js"></script>

<script src="https://cdnjs.cloudflare.com/ajax/libs/3Dmol/2.0.1/3Dmol.js"></script>

2.1 创建场景

<div id="container" style="height:800px;width:800px"></div>
import * as $3Dmol from '3dmol/build/3Dmol.js';

const container=document.getElementById("container");
//创建视图
const viewer = createViewer(container, {backgroundColor: "orange"});

//添加绿色球体
    viewer.addSphere({center: {x: 0, y: 0, z: 0}, radius: 10.0, color: "green"});
    //缩放适配渲染内容大小
    viewer.zoomTo();
    //开始渲染
    viewer.render();
    //缩放动画  2秒缩放到0.8视角
    viewer.zoom(0.8, 2000);

效果如下

image.png

常用的几个配置

  • backgroundColor背景颜色

  • antialias是否开启抗锯齿

  • backgroundAlpha背景透明度,范围[0~1]

  • defaultcolors3D分子模型默认颜色,如使用JS模型颜色$3Dmol.elementColors.Jmol,那么3D分子模型的样式会是:

    • H氢原子白色
    • O氧原子红色
    • 元素原子都会配置对应颜色作为区别标识
  • 更多视图方法的配置可以看官网文档createViewer

常用的的方法

  • render()渲染场景内容
  • resize()调整大小,用于窗口大小变化的时候
  • spin(axis, speed)视角变化,自动绕某个轴旋转
  • clear()清空场景所有
  • setConfig(c)修改更新配置
  • setStyle()修改模型全局样式配置
  • 更多视图对象操作请看GLViewer`

2.2 添加形状并设置样式

添加球体

const sphere= viewer.addSphere({center: {x: 2.0, y: 0, z: 0}, radius: 1.0, color: "red"});

const sphere1=viewer.addSphere({center: {x: -2.0, y: 0, z: 0}, radius: 1.0, color: "red", opacity: 0.5});

参数说明

  • center中心坐标
  • radius半径大小
  • color颜色
  • opacity透明度 image.png 可以使用updateStyle修改样式
 sphere.updateStyle({color: "blue"});
 viewer.render();

注意:形状修改样式后记得执行 viewer.render才能重新渲染生效。

添加柱体

viewer.addCylinder({
      start: {x: 1.0, y: 0.0, z: 0.0},
      end: {x: -1.0, y: 0.0, z: 0.0},
      radius: 0.2,
      color: "white",
      fromCap: $3Dmol.CAP.FLAT, 
      toCap: $3Dmol.CAP.ROUND  
    });

参数说明

  • start 开始坐标

  • end结束坐标

  • radius半径大小

  • color颜色

  • fromCap开始端是否圆角,NONE无,FLAT平角, ROUND圆角

  • toCap结束端是否圆角,NONE无,FLAT平角, ROUND圆角

image.png

可以开启虚线,多段柱体

viewer.addCylinder({
      start: {x: 1.0, y: 0.0, z: 0.0},
      end: {x: -1.0, y: 0.0, z: 0.0},
      radius: 0.2,
      color: "white",
      fromCap: $3Dmol.CAP.FLAT,
      toCap: $3Dmol.CAP.FLAT,
      dashed: true,
      dashLength: 0.5,
      gapLength: 0.3
    });

参数说明

  • dashed是否开启虚线
  • dashLength段长度
  • gapLength间隔长度

image.png

添加标签

  viewer.addLabel("分子结构", {
      alignment: "center",
      font: "sans-serif",
      fontSize: 18,
      fontColor: "white",
      fontOpacity: 1,
      borderThickness: 2.0,
      borderColor: "blue",
      borderOpacity: 1,
      backgroundColor: "black",
      backgroundOpacity: 0.5,
      position: {x: 0.0, y: 0.0, z: 0.0},
      inFront: true,
      showBackground: true
    });

参数说明

  • alignment对齐方式,可选项"topLeft", "topCenter", "topRight", "centerLeft", "center", "centerRight", "bottomLeft", "bottomCenter", "bottomRight",也可以是xy坐标
  • position标签位置
  • inFront是否总在模型前面
  • font字体系列
  • fontSize字体大小
  • fontColor字体颜色
  • fontOpacity字体透明度
  • borderThickness边框宽度
  • borderColor边框颜色
  • borderOpacity边框透明度
  • showBackground是否显示背景
  • backgroundColor背景颜色
  • backgroundOpacity背景透明度

image.png

还有其他更多形状,如矩形,箭头,曲线等,可以到官方API文档查看怎么使用

https://3dmol.org/doc/GLViewer.html

2.3 加载3D分子结构模型

通过请求下载获取并添加D分子结构模型,推荐这种方法,3Dmol.js自动识别格式并解析加载,很方便。

download("/1ycr.pdb", viewer, {multimodel: true, frames: true}, (model: any) => {
      console.log("🚀 ~ model:", model);
      viewer.setStyle({}, {cartoon: {color: "spectrum"}});
      viewer.render();
    });

viewer.setStyle设置全局样式,使得3D分子结构结构以卡通模式显示

image.png

通过打印的模型信息,可以看到模型相关数据有A,B,C三条链,α,β,γ三个角的角度

image.png

设置分子结构链A为绿色

viewer.setStyle({chain: "A"}, {cartoon: {color: "green"}});

image.png

通过模型格式文本解析添加3D分子结构

这是7个水分子的3D分子结构模型文本

symmetry c1
 OpenBabel01312416543D

 21 14  0  0  0  0  0  0  0  0999 V2000
   0.59904   0.98737  -3.22086 O   0  0  0  0  0  0
  -1.51456   1.20243  -0.31112 O   0  0  0  0  0  0
   0.13045   0.14890  -1.25059 O   0  0  0  0  0  0
  -0.20952  -0.68359  -1.35644 H   0  0  0  0  0  0
  -0.69349   0.71484  -0.83445 H   0  0  0  0  0  0
  -0.05336   1.70041  -3.27063 H   0  0  0  0  0  0
   0.39550   0.49975  -2.28181 H   0  0  0  0  0  0
  -2.04082   1.71243  -0.64258 H   0  0  0  0  0  0
  -1.91286   0.87596   0.33191 H   0  0  0  0  0  0
  -0.80317  -1.78194  -1.51703 O   0  0  0  0  0  0
   4.83471   2.50607   2.80654 H   0  0  0  0  0  0
  -2.85573  -2.53790  -1.84489 O   0  0  0  0  0  0
  -0.13238  -2.61116  -1.78592 H   0  0  0  0  0  0
  -1.84863  -2.10567  -1.62688 H   0  0  0  0  0  0
  -3.18539  -2.82307  -2.85475 H   0  0  0  0  0  0
  -3.60191  -2.86990  -1.10817 H   0  0  0  0  0  0
   5.44460   1.75213   2.56206 O   0  0  0  0  0  0
   6.37580   2.09935   2.45041 H   0  0  0  0  0  0
   1.49202  -0.02736   0.38172 O   0  0  0  0  0  0
   1.15940   0.32819   1.19843 H   0  0  0  0  0  0
   0.74030   0.06841  -0.41057 H   0  0  0  0  0  0
  4  3  1  0  0  0
  5  3  1  0  0  0
  6  1  1  0  0  0
  7  1  1  0  0  0
  8  2  1  0  0  0
  9  2  1  0  0  0
 13 10  1  0  0  0
 14 10  1  0  0  0
 15 12  1  0  0  0
 16 12  1  0  0  0
 17 11  1  0  0  0
 18 17  1  0  0  0
 20 19  1  0  0  0
 21 19  1  0  0  0
M  END
$$$$

设置采用Jmol模型颜色风格,


const  viewer = $3Dmol.createViewer(container, {
      defaultcolors: $3Dmol.elementColors.Jmol,
     backgroundColor: "#f9f9f9"      
    });
    
  viewer.addModel(data, 'sdf');
  viewer.setStyle(
        {},
        {
          stick: {
            radius: 0.15
          },
          sphere: {
            scale: 0.25
          }
        }
      );
  • 其中模型格式可选'pdb', 'sdf', 'xyz', 'pqr', 'mol2',这个需要人工识别格式。非专业人士真不懂,所以还是推荐download
  • viewer.setStyle通过设置全局样式为棍棒模式

image.png

2.4 切换3D分子结构模式

有四种常见的分子展示模式

  const modes = [
    {label: "球棍模式", value: "ball"},
    {label: "线框模式", value: "line"},
    {label: "球体模式", value: "spacefill"},
    {label: "卡通模式", value: "cartoon"}
  ];

线框模式的全局样式设置

viewer.setStyle(
            {},
            {
              stick: {
                radius: 0.15
              }
            }
          );

20250930_113752.gif

球体模式的全局样式设置

viewer.setStyle(
            {},
            {
              sphere: {
                scale: 0.75
              }
            }
          );

20250930_114342.gif 球棍模式的全局样式设置

viewer.setStyle(
            {},
            {
              stick: {
                radius: 0.15
              },
              sphere: {
                scale: 0.25
              }
            }
          );

20250930_114244.gif

卡通模式的全局样式设置,注意卡通模式是特殊的,仅适用于蛋白质或核酸二级结构的可视化,如果是水分子之类则可能空白一片或者只有线。

viewer.setStyle({}, {cartoon: {color: "spectrum"}});

20250930_115437.gif

让视图内容自动沿y轴旋转

viewer.spin("y");

给模型原子添加标签

image.png

打印模型信息,可以看到在atoms属性有所有原子的信息

  • elem该原子的元素
  • xyz对应原子的位置

遍历所有原子即可添加所有元素标签

 model.atoms.forEach((item: any) => {
            viewer.addLabel(item.elem, {
            alignment:'center',
              position: {
                x: item.x,
                y: item.y,
                z: item.z
              },
              fontColor: "black",
              showBackground: false
            });
          });

image.png

当然可以通过移除所有标签和模型,添加新的模型和标签

  viewer.removeAllLabels();
 viewer.removeAllModels();

销毁视图前记得清空

viewer.clear();

3. 总结

  1. 3Dmol.js的缩放操作跟Three.js的OrbitControls有些不同,滚轮缩放是相反的,向前滚是缩小,向后滚是放大,并且找了一圈配置项,没有发现修改缩放操作的配置。
  2. 3Dmol.js的不能移动操作,只能旋转。不过可以通过viewer.setView()来配置视角位置。
  • 3D分子结构不论大小都可以通过viewer.zoomTo()来适配视角,可以通过viewer.zoom(zoom,time)设置缩放等级
  1. 3Dmol.js限制缩放视角大小的参数很鸡肋,是根据相机距离物体的距离根据判断条件,而不同分子结构的大小是不限的,有的总大小可能是100+,有的总大小只有0.5,那么限制值就得谨慎计算出合适值才行,否则就会导致显示的分子结构太小或太大。
const viewer= $3Dmol.createViewer(container,{
  lowerZoomLimit: 100,
    upperZoomLimit: 200,
 })
 
viewer.setZoomLimits(lower,upper);

通过viewer.getView()返回视角参数[pos.x, pos.y, pos.z, rotationGroup.position.z, q.x, q.y, q.z, q.w ],对应平移、缩放和旋转四元数,可以获取当前视角距离z

然后我以为通过zoom(minZoom)和zoom(maxZoom)获取对应缩放值的视角距离限制,结果发现lowerZoomLimitupperZoomLimit跟getView的z距离不是一个东西。

 viewer.zoom(0.5);
    const v = viewer.getView();
    const minZ = v[3];
    viewer.zoom(3);
    const v1 = viewer.getView();
    const maxZ = v1[3];
    viewer.setZoomLimits(minZ, maxZ);
    viewer.render();

于是,查看了一下文档,有个setViewChangeCallback方法可以监听视角变化

viewer.setViewChangeCallback((view: number[]) => {
        console.log("🚀 ~ view:", view[3]);
      });

image.png

image.png

可以看到,物体离摄像机越远,即缩小,则view的z距离值越小,物体离摄像机越近,即放大,则view的z距离值越大。

3.1 实现通用视角范围限制

第一步,遍历模型元素,获取其包围框范围

const box = {
        minx: Number.MAX_SAFE_INTEGER,
        miny: Number.MAX_SAFE_INTEGER,
        minz: Number.MAX_SAFE_INTEGER,
        maxx: Number.MIN_SAFE_INTEGER,
        maxy: Number.MIN_SAFE_INTEGER,
        maxz: Number.MIN_SAFE_INTEGER
      };
      model.atoms.forEach((item: any) => {
        box.minx = Math.min(box.minx, item.x);
        box.maxx = Math.max(box.maxx, item.x);

        box.miny = Math.min(box.miny, item.y);
        box.maxy = Math.max(box.maxy, item.y);

        box.minz = Math.min(box.minz, item.z);
        box.maxz = Math.max(box.maxz, item.z);
      });

      const size = {
        x: box.maxx - box.minx,
        y: box.maxy - box.miny,
        z: box.maxz - box.minz
      };
const distance = Math.max(size.x, size.y, size.z);

第二步,zoomTo()后获取最佳适配视角作为基础距离

const v = viewer.getView();
 const baseDistance = v[3];

第三步,根据包围框大小,设置缩放最大最小距离z,监听视角变化,基于基础距离baseDistance进行对比和限制

const minDistance = -distance * 3;
      const maxDistance = distance * 2;
      viewer.setViewChangeCallback((view: number[]) => {
        const z = view[3];
        if (z < baseDistance + minDistance) {
          //缩小限制
          view[3] = baseDistance + minDistance;
          viewer.setView(view);
        } else if (z > baseDistance + maxDistance) {
          //放大限制
          view[3] = baseDistance + maxDistance;
          viewer.setView(view);
        }
      });

20250930_182140.gif

4. Github地址

https://github.com/xiaolidan00/3dmol-project 20250930_171026.gif

参考

Canvas 高性能K线图的架构方案

作者 VincentFHR
2025年10月4日 04:42

前言

证券行业,最难的前端组件,也就是k线图了。
指标还可以添加、功能还可以扩展, 但思路要清晰。
作为一个从证券行业毕业的前端从业者,
我想分享下自己的项目经验。

1、H5 K线图,支持无限左右滑动、样式可随意定制;
2、纯canvas制作,不借助任何第三方图表库;
3、阅读本文,需要有 canvas 基础知识;

滑动K线图组件    Github Page 预览地址

股票详情页源码    Github Page 预览地址

注意:以上的 demo 还有一些 bug, 没时间修复, 预览地址是直接在 github 上部署的, 所以最好通过 vpn 科学上网, 否则可能访问不了。另外, 上面的股票详情页, 还没有做自适应,等我有时间再改。

一、先看最终的效果

1、GIF动图如下

gif222.gifgif.gif

2、支持样式自定义

用可以屏幕取色器,获取东方财富的配色 codeinword.com/eyedropper

图一、图二,是参考东方财富黑白皮肤的配色, 图三是参考腾讯自选股的配色。

q1.pngq2.pngq3.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,
    );
  }
  
  //画出两个点形成的直线
  private drawLine(x1: number, y1: number, x2: number, y2: number) {
    this.canvasCxt.moveTo(x1, y1);
    this.canvasCxt.lineTo(x2, y2);
    this.canvasCxt.stroke();
  }

画各类均线

1、首先计算出一屏的股价最大值 max , 股价最小值 min ,成交量最大值 maxAmount。

2、当某一个点的均线为 value, 根据最大值、最小值、索引index, 计算出坐标点(x, y), 画均线的时候, 第一个点用 moveTo(x0, y0),其他点用 lineTo(xn yn), 最后 stroke 连起来即可。

3、当然, 每一条线设置下颜色, 即 stokeStyle。

  //画出各类均线
  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.png999.png

当收盘价大于等于开盘价, 选用上面左边红色的样式; 当收盘价小于开盘价, 选用上面右边绿色的样式。

以红色蜡烛为例, 最高点 A(x0, y0),最低点是 B(x1, y1),
高度 height、宽度 width 都是相对于坐标轴的,
红色矩形左上角的顶点是 D(x, y)。

为了画出红色蜡烛, 先后顺序别搞混:

  1. AB 这条竖线,通过 moveTo,lineTo 画出来;
  2. 定义一个矩形 cxt.rect(x, y, width, heigth);
  3. 通过 fill 填充白色背景, 同时覆盖后面的红色竖线;
  4. 再通过 stroke 描出红色边框

按照上面这个顺序, 竖线会被覆盖掉,同时,矩形内部的白色填充不会挤压矩形的红色边框, 如果先 stroke 再 fill,容易出现白色填充覆盖红色边框,矩形可能会变模糊,或者使得红色变淡,极其不友好,所以按照我上面的顺序,可以减少不必要的麻烦。

画出文字

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 事件中触发, 那怎么区分开呢? 典型的 touchstart、 touchmove 、 touchend 解耦如下:

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 的数据量。

九、性能优化

其实, 做到上面的设计,性能已经很好了,可以监控帧率来看下滑动的流畅程度。

总结下我做了什么操作,来提高整体的性能:

1、分层渲染

将K线图画在3个canvas上。

  1. 不动层只需要绘画一次;
  2. 变动层根据需要而变动;
  3. 交互层独立出来,不会影响其它层,变动层的大量蜡烛柱体、均线等也不会受交互层的影响

2、离屏渲染

当需要在K线上标注一些icon时, 这些 icon 可以先离屏渲染, 需要的时候, 再copy到变动层对应的位置,这样比临时抱佛脚去画,要省很多时间,也能提高新能。

3、设置数据缓冲区

就是屏幕只渲染一屏数据, 但是在当前屏的左右两边,各缓存了2屏数据, 超过5屏数据的时候,及时裁掉多余的数据, 这样arraylist的数据量始终保持在5屏, 控制了数据量,有效的控制了占用空间。

4、节流防抖

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

github page 的部署需要将仓库设置为 public, 这个我挺反感的, 可以用 vercel 部署, 也就是将 github 账号与 vercel 关联起来, 项目的 package.js 的 homepage 设置为 “.” , 然后 vercel 可以点击一下, 一键部署, 常见的命令行如下:

# 1. 安装 Vercel CLI
npm install -g vercel

# 2. 在项目根目录登录 Vercel
vercel login

# 3. 部署项目
npm run build
vercel --prod

# 或者直接部署 build 文件夹
vercel --prod --build
❌
❌