likes
comments
collection
share

实践[前端] threejs实现分层展示关系图

作者站长头像
站长
· 阅读数 13

有个大屏展示需求,需要体现出层级感,而传统的2D展示很难体现出层级感,所以我们就使用threejs来实现关系图的层级展示,展示的内容为图书管每一层的关系结构,同时每一层都需要关联上一层,并实现点击标签跳转到每一层。 效果展示如下。

实践[前端] threejs实现分层展示关系图

项目搭建

本项目使用vite搭建原生ts页面开发,目的是为了方便移植到任何创建用户界面的javascript库中(VUE,React) 代码主题结构如下,该项目采用ts编写。

├── package.json
├── package-lock.json
├── public                     静态资源库
│   ├── 1.png
│   ├── 2.png
│   ├── 3.png
│   ├── 43.png
│   ├── 4.png
│   └── grey.png
├── src                        源码库
│   ├── index.ts
│   ├── mock                   模拟数据
│   │   └── mock.ts
│   └── modules               threejs逻辑模块
│       ├── Camera.ts         相机模块
│       ├── Control.ts        控制器模块
│       ├── CssRenderer.ts    css渲染库
│       ├── DrawData.ts       数据处理
│       ├── Experience.ts     入口
│       ├── modules           模型库
│       │   ├── Axias.ts      坐标
│       │   └── ShaderBox.ts  模型主体
│       ├── PostProcessing.ts 后处理
│       ├── Renderer.ts       threejs渲染器
│       ├── Resource.ts       资源注册
│       └── utils             工具类库
│           ├── Sizes.ts      尺寸相关
│           └── Time.ts       定时器相关
├── tsconfig.json
└── vite.config.ts

文件描述

  • Timer和Size采用了EventEmitter实现对时间和尺寸状态的异步监听,可以理解为使用了发布订阅模式。Timer的主要功能提供了动画帧和时间相关的增量属性,Size提供了resize时间的绑定和相关的尺寸信息
  • Experience使用了单例模式来装,提供了单一的对象,同时其它模块需要使用该对象的其它属性是,只需简单初始化即可,避免了对象间的引用传递。

使用依赖

"dependencies": {
    "@esbuild-plugins/node-globals-polyfill": "^0.2.3",
    "@esbuild-plugins/node-modules-polyfill": "^0.2.2",
    "@types/three": "^0.152.1",
    "3d-force-graph": "^1.71.4",
    "events": "^3.3.0",
    "gsap": "^3.12.2",
    "process-es6": "^0.11.6",
    "rollup-plugin-node-polyfills": "^0.2.1",
    "three": "^0.153.0",                                    threejs
    "three-forcegraph": "^1.41.9"                           关系图生成器
}

模型绘制

框体绘制

实践[前端] threejs实现分层展示关系图

该框体主要包括三个部分组成,本别是底座,四个侧壁和底下的文字描述。

底座绘制

首先底座绘制比较简单,只需要创建一个立方体即可,但是需要使用着色器绘制表面纹理为蓝色渐变,但是底座的贴图需要使用canvas生成alpla图像并叠加渲染

    const fragmentSharder = `
        precision highp float;
        uniform sampler2D u_texture;
        uniform float u_height;
        varying vec3 v_position;
        varying vec2 v_uv;
        varying vec3 v_normal;

        void main() {
          vec4 textureColor = texture2D(u_texture, 1.0 - v_uv);
            float height_opc = 1.0 - ((v_position.y + 60.0) / 150.0);
            gl_FragColor = vec4(0, 0.521, 0.964, height_opc);
          if (abs(v_normal.y) < 0.9) {
            gl_FragColor += textureColor;
          }
        }`
    const vertexSharder = `
        precision highp float;
        varying vec3 v_position;
        varying vec2 v_uv;
        varying vec3 v_normal;

        void main() {
          v_position = position;
          v_uv = uv;
          v_normal = normalize(mat3(modelMatrix) * normal);
          vec4 model_position = modelMatrix * vec4(position, 1.0);
          gl_Position = projectionMatrix * viewMatrix * model_position;
        }`
    const bottomGeometry = new THREE.BoxGeometry(2000, 70, 2000, 10, 3, 10);
    const bottomMaterial = new THREE.ShaderMaterial({
      uniforms: {
        u_height: { value: 150 },
        u_time: { value: 0 },
        u_texture: { value: this.getCanvasTexture(name) },
      },
      side: THREE.FrontSide,
      fragmentShader,
      vertexShader,
      transparent: true,
    });
底部纹理贴图图像获取

这部分没什么好说的,就是画布的基本使用


  getCanvasTexture(text: string, col: string = "#ffffff") {
    const canvas = document.createElement("canvas");
    canvas.width = 2000;
    canvas.height = 70;
    const color = col.replace("#", "");
    const colorList = [
      `0x${color[0]}${color[1]}`,
      `0x${color[2]}${color[3]}`,
      `0x${color[4]}${color[5]}`,
    ]
      .map((item) => Number(item))
      .map((item) => Math.floor((item / 255) * 100) / 100);
    const context = canvas.getContext("2d") as CanvasRenderingContext2D;
    context.beginPath();
    context.beginPath();
    const grd = context.createLinearGradient(0, 0, 0, 800);
    grd.addColorStop(0, "rgba(0,0,0,0)");
    grd.addColorStop(1, `rgba(${colorList.join(",")},1)`);
    context.fillStyle = grd;
    context.fillRect(0, 0, 400, 100);
    context.beginPath();
    context.fillStyle = "#ffffff"; //文本填充颜色
    context.font = "bold 70px 宋体"; //字体样式设置
    context.textBaseline = "middle"; //文本与fillText定义的纵坐标
    context.textAlign = "center"; //文本居中(以fillText定义的横坐标)
    context.fillText(text, 1000, 38, 800);
    return new THREE.CanvasTexture(canvas);
  }
侧壁绘制

侧壁绘制与底座绘制基本类似,只不过需要对四个面进行编排,我们需要定义一个二维矩阵来作为渲染的配置项

    let pos = [
      [-1, -1, 1, 0, 1000],
      [-1, +1, 2, 1, -1000],
      [+1, +1, 1, 0, 1000],
      [+1, -1, 2, 1, -1000],
    ];
    {
      for (let idx in pos) {
        const innerGroup = new THREE.Group();
        const p = pos[idx];
        const geometry2 = new THREE.PlaneGeometry(2200, 100);
        const material2 = new THREE.ShaderMaterial({
          uniforms: {
            u_height: { value: 900 },
            u_time: { value: 0 },
          },
          linewidth: 2,
          side: THREE.DoubleSide,
          fragmentShader: `
            precision highp float;
            uniform float u_height;
            varying vec3 v_position;
            varying vec2 v_uv;
            void main() {
              float height_opc = 1.0 - ((v_position.y+400.0) / 700.0);
              gl_FragColor += vec4(0, 0.521, 0.964, 0.2);
            }`,
          vertexShader: `
            precision highp float;
            varying vec3 v_position;
            void main() {
              v_position = position;
              vec4 model_position = modelMatrix * vec4(position, 1.0);
              gl_Position = projectionMatrix * viewMatrix * model_position;
            }`,
          transparent: true,
          opacity: 1,
        });
        const mesh2 = new THREE.Mesh(geometry2, material2);
        mesh2.position.setY(y - 400);
        mesh2.position.setZ(1050);
        innerGroup.position.setY(y - 20);
        innerGroup.position.setZ(1000);
        switch (Number(idx)) {
          case 0:
            break;
          case 1:
            innerGroup.position.setZ(-1000);

            mesh2.position.setZ(-1100);
            break;
          case 2:
            innerGroup.rotateY(Math.PI / p[2] / 2);
            innerGroup.position.setX(1000);
            innerGroup.position.setZ(0);

            mesh2.rotateY(Math.PI / p[2] / 2);
            mesh2.position.setX(1100);
            mesh2.position.setZ(0);
            break;
          case 3:
            innerGroup.rotateY(Math.PI / p[2]);
            innerGroup.position.setX(-1000);
            innerGroup.position.setZ(0);

            mesh2.rotateY(Math.PI / p[2]);
            mesh2.position.setX(-1100);
            mesh2.position.setZ(0);
            break;
        }
        group.add(innerGroup);
      }
    }

关系图的绘制

关系图使用了three-forcegraph关系图库,但该库的事件与threejs默认事件冲突导致无法进行节点拖拽,最终放弃了关系图的拖拽交互,后期可以自己开发一个,也许是用的不熟悉。

效果

实践[前端] threejs实现分层展示关系图

this.graphList.forEach((graph, index) => {
      globalGroup.add(this.getBox(index * 1000, graph.name));
      graph.d3Force("charge")?.strength(-600); // 设置斥力强度
      graph.d3Force("link")?.distance(800); // 设置节点之间的距离

      globalGroup.add(graph);
    });

节点绘制

      graph.d3Force("positioning", (alpha) => {
        // 遍历所有节点
        graph.graphData().nodes.forEach((node: any) => {
          // console.log(node);
          // 限制节点 X 坐标在边界范围内
          node.x = Math.max(-width / 2, Math.min(width / 2, node.x as any));
          // 限制节点 Z 坐标在边界范围内
          node.z = Math.max(-depth / 2, Math.min(depth / 2, node.z as any));
          // 限制节点 Z 坐标在边界范围内
          node.y = Math.max(-height / 2, Math.min(height / 2, node.y as any));
        });
      });
      graph.position.setY(1000 * index);
      graph.cooldownTicks(200);

线条绘制


      graph.linkThreeObject((node: any) => {
        const colors = new Float32Array(
          [172, 109, 118, 41, 22, 222].map((a) => a / 255)
        );

        const material = new THREE.LineBasicMaterial({
          color: "#3e5a79",
          transparent: false,
          opacity: 1,
        });
        const geometry = new THREE.BufferGeometry();
        return new THREE.Line(geometry, material);
      });

节点渲染使用css渲染器渲染node,图片并添加鼠标事件


      // 处理节点
      graph.nodeThreeObject((node: any) => {
        const group = new THREE.Group();
        const img = document.createElement("div");
        let imgsrc = "1.png";
        if (!isNaN(node.color)) {
          imgsrc = `${node.color}.png`;
        }
        img.innerHTML = `<div 
          style="
            cursor: pointer;
            background-image:url(${imgsrc});
            background-size: 100%;
            width: 80px;
            height: 80px;
            position:relative;
            top: 35px;
            "></div>`;
        const objImg = new CSS3DSprite(img);
        objImg.layers.set(0);
        objImg.position.setY(30);
        document.body.appendChild(img);
        group.add(objImg);
        const meta = this.experience.drawData.dataMeta[node.index];
        img.onmouseenter = (e) => {
          const d = e.target as HTMLDivElement;
          this.experience.screenToolTip.innerHTML = `
          <div class="d3-tooltip padv10 padh20 radius10 " style='background:rgb(0,77,155);color:#fff;padding: 10px;border-radius: 5px;' >
          <div class="title bold">${node.id}</div>
          <div class="desc font14 marv10">实体类型:${meta["实体类型"]}</div>
          <div class="desc font14">中文名称:${meta["中文名称"]}</div>
        </div>
          `;
          d.onmousemove = (e) => {
            console.log(e.x);
            const { width, height } =
              this.experience.screenToolTip.getBoundingClientRect();

            this.experience.screenToolTip.style.top = e.y - 100 + "px";
            this.experience.screenToolTip.style.left = e.x - width / 2 + "px";
          };
        };
        img.onmouseleave = (e) => {
          const d = e.target as HTMLDivElement;
          this.experience.screenToolTip.innerHTML = "";
          d.onmousemove = (e) => {};
        };
        const dom = document.createElement("div");
        dom.innerHTML = `<div style="color:${
          linkKV[node.color]
        };font-size:24px;">${node.id}</div>`;

        const obj = new CSS3DSprite(dom);
        obj.layers.set(0);
        obj.position.setY(30);
        document.body.appendChild(dom);
        group.add(obj);
        return group;
      });

每一层的移动

通过传入节点的index下标,实现每一层的滚动展示,使用gsap作为过渡动画,外部的调用是通过调用Experience提供的资源来定向查找依赖函数,实现方法调用。


  checkPosition(name: string) {
    console.log(name);
    console.log(this.graphList.map((item) => item.name));
    const index = this.graphList.findIndex((graph) => graph.name === name);
    console.log(index);
    this.experience.camera.perspectiveCamera.lookAt(0, 0, 0);
    this.experience.camera.perspectiveCamera.position.set(3000, 600, 2600);

    this.experience.control.setPosition(new THREE.Vector3(0, 0, 0));
    if (-1 !== index) {
      gsap.to(this.globalGroup.position, {
        y: -1000 * index,
        yoyo: true,
        duration: 1,
      });
    }
  }

总结

该需求使用了Threejs的Scene, PerspectiveCamera, OrbitControls, WebGLRenderer, CSS3DRenderer, CSS3DSprite, Geometry, Material, GLSL, 等特性,配合GSAP实现动画效果。threejs我这边的使用场景不多,之后如果想提升,还是得依靠业余时间的不断学习进步。

存在的问题

该方案在渲染节点时由于节点是dom元素,导致在有些时候展示会超出框体

最开始的设计是球平铺,球与球之间通过管道链接,表现依赖关系,可惜被驳回了,哎,改天再写吧,这个也比较简单,如何布局才能难点。

实践[前端] threejs实现分层展示关系图