• 为了提高出图效率,我做了一款可视化制作气泡图谱的小工具


    嗨,大家好,我是徐小夕,之前和大家分享了很多可视化低代码的最佳实践,今天和大家分享一下我基于实际场景开发的小工具——BubbleMap

    c26303ebe2ebfb1cf67b54fc3d0faf1c.gif


    demo地址:http://wep.turntip.cn/design/bubbleMap

    开发背景

    之前在公司做图表开发的时候涉及到了气泡图的开发,但是由于运营部对这种图需求比较大,所以每次都要找研发人员来支持,做图表数据更新。长此以往就导致研发小伙伴占用了很多琐碎的时间来做这种基础任务,运营小同学也觉得很不方便。

    4257040512cbcb37bca7826696dc5e69.png
    image.png

    基于这样的场景,我就想到了能不能提供一种可视化的方案,让运营人员全权接管这类需求,然后我就开始规划,其实只需要几步:

    • 气泡图谱实现

    • 在线编辑数据

    • 实时更新图表

    最后基于不断的演算推理+实践,这款小工具也成功上线,如果大家有类似的需要,也可以直接免费使用。接下来我就和大家分享一下它的实现思路。(PS: 如果大家想参考实现源码,可以在趣谈前端公众号回复气泡源码)

    实现思路

    3412fed20ae4a1b8581688cec5080c29.png
    image.png

    整个工具其实只需要分为两部分:

    • 画布图表区

    • 数据编辑区

    画布图表区用来预览图表效果,我们可以使用市面上比较成熟的开源图表库比如EchartAntv来实现,这里我选择了蚂蚁的Antv

    15c5936b80713b244a44fdccce9c024e.png
    image.png

    对于数据编辑区,我们可以用很多方式来实现,比如:

    • 表格组件

    6236b17229cdb0f858255298589886d2.png
    image.png

    首先想到的就是 antd 的可编辑表格组件,它提供了完整的案例demo,我们直接基于源码改吧改吧就能用。

    • 电子表格

    576d09535791227b59740ac538669731.png
    image.png

    电子表格也是不错的选择,我们可以用 excel 的表格编辑方式来编辑数据, 比如常用的表格开源项目handsontable.js

    • 嵌套表单

    58011ee5b9ddf8b7cb2207166c4ccd54.gif
    6241.gif

    当然这种方式成本也很低,前端小伙伴们可以用antdform组件或者其他UI组件库实现类似的效果。我在实现气泡图谱工具的时候就是采用的这种方案。

    嵌套表单代码案例如下:

    1. import React from 'react';
    2. import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
    3. import { Button, Form, Input, Space } from 'antd';
    4. const onFinish = (values: any) => {
    5.   console.log('Received values of form:', values);
    6. };
    7. const App: React.FC = () => (
    8.   
    9.     name="dynamic_form_nest_item"
    10.     onFinish={onFinish}
    11.     style={{ maxWidth: 600 }}
    12.     autoComplete="off"
    13.   >
    14.     "data">
    15.       {(fields, { add, remove }) => (
    16.         <>
    17.           {fields.map(({ key, name, ...restField }) => (
    18.             'flex', marginBottom: 8 }} align="baseline">
    19.               
    20.                 {...restField}
    21.                 name={[name, 'name']}
    22.                 rules={[{ required: true, message: '请输入字段名称' }]}
    23.               >
    24.                 "字段名称" />
    25.               
    26.               
    27.                 {...restField}
    28.                 name={[name, 'value']}
    29.                 rules={[{ required: true, message: '请输入字段值' }]}
    30.               >
    31.                 "字段值" />
    32.               
    33.                remove(name)} />
    34.             
    35.           ))}
    36.           
    37.             type="dashed" onClick={() => add()} block icon={}>
    38.               Add field
    39.             
    40.           
    41.         
    42.       )}
    43.     
    44.     
    45.       type="primary" htmlType="submit">
    46.         Submit
    47.       
    48.     
    49.   
    50. );
    51. export default App;

    当然气泡图我这里采用的是antv/g6:

    fed084c7d695c8a432e9cbf3fe93e736.png
    image.png

    由于g6学习有一定成本,这里简单介绍一下使用。

    我们先注册一个气泡的节点:

    1. G6.registerNode(
    2.           'bubble',
    3.           {
    4.             drawShape(cfg: any, group: any) {
    5.               const self: any = this;
    6.               const r = cfg.size / 2;
    7.               // a circle by path
    8.               const path = [
    9.                 ['M', -r, 0],
    10.                 ['C', -r, r / 2, -r / 2, r, 0, r],
    11.                 ['C', r / 2, r, r, r / 2, r, 0],
    12.                 ['C', r, -r / 2, r / 2, -r, 0, -r],
    13.                 ['C', -r / 2, -r, -r, -r / 2, -r, 0],
    14.                 ['Z'],
    15.               ];
    16.               const keyShape = group.addShape('path', {
    17.                 attrs: {
    18.                   x: 0,
    19.                   y: 0,
    20.                   path,
    21.                   fill: cfg.color || 'steelblue',
    22.                 },
    23.                 // must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
    24.                 name: 'path-shape',
    25.               });
    26.         
    27.               const mask = group.addShape('path', {
    28.                 attrs: {
    29.                   x: 0,
    30.                   y: 0,
    31.                   path,
    32.                   opacity: 0.25,
    33.                   fill: cfg.color || 'steelblue',
    34.                   shadowColor: cfg.color.split(' ')[2].substr(2),
    35.                   shadowBlur: 40,
    36.                   shadowOffsetX: 0,
    37.                   shadowOffsetY: 30,
    38.                 },
    39.                 // must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
    40.                 name: 'mask-shape',
    41.               });
    42.         
    43.               const spNum = 10// split points number
    44.               const directions: number[] = [],
    45.                 rs: number[] = [];
    46.               self.changeDirections(spNum, directions);
    47.               for (let i = 0; i < spNum; i++) {
    48.                 const rr = r + directions[i] * ((Math.random() * r) / 1000); // +-r/6, the sign according to the directions
    49.                 if (rs[i] < 0.97 * r) rs[i] = 0.97 * r;
    50.                 else if (rs[i] > 1.03 * r) rs[i] = 1.03 * r;
    51.                 rs.push(rr);
    52.               }
    53.               keyShape.animate(
    54.                 () => {
    55.                   const path = self.getBubblePath(r, spNum, directions, rs);
    56.                   return { path };
    57.                 },
    58.                 {
    59.                   repeat: true,
    60.                   duration: 10000,
    61.                 },
    62.               );
    63.         
    64.               const directions2: number[] = [],
    65.                 rs2: number[] = [];
    66.               self.changeDirections(spNum, directions2);
    67.               for (let i = 0; i < spNum; i++) {
    68.                 const rr = r + directions2[i] * ((Math.random() * r) / 1000); // +-r/6, the sign according to the directions
    69.                 if (rs2[i] < 0.97 * r) rs2[i] = 0.97 * r;
    70.                 else if (rs2[i] > 1.03 * r) rs2[i] = 1.03 * r;
    71.                 rs2.push(rr);
    72.               }
    73.               mask.animate(
    74.                 () => {
    75.                   const path = self.getBubblePath(r, spNum, directions2, rs2);
    76.                   return { path };
    77.                 },
    78.                 {
    79.                   repeat: true,
    80.                   duration: 10000,
    81.                 },
    82.               );
    83.               return keyShape;
    84.             },
    85.             changeDirections(num: number, directions: number[]) {
    86.               for (let i = 0; i < num; i++) {
    87.                 if (!directions[i]) {
    88.                   const rand = Math.random();
    89.                   const dire = rand > 0.5 ? 1 : -1;
    90.                   directions.push(dire);
    91.                 } else {
    92.                   directions[i] = -1 * directions[i];
    93.                 }
    94.               }
    95.               return directions;
    96.             },
    97.             getBubblePath(r: number, spNum: number, directions: number[], rs: number[]) {
    98.               const path = [];
    99.               const cpNum = spNum * 2// control points number
    100.               const unitAngle = (Math.PI * 2) / spNum; // base angle for split points
    101.               let angleSum = 0;
    102.               const sps = [];
    103.               const cps = [];
    104.               for (let i = 0; i < spNum; i++) {
    105.                 const speed = 0.001 * Math.random();
    106.                 rs[i] = rs[i] + directions[i] * speed * r; // +-r/6, the sign according to the directions
    107.                 if (rs[i] < 0.97 * r) {
    108.                   rs[i] = 0.97 * r;
    109.                   directions[i] = -1 * directions[i];
    110.                 } else if (rs[i] > 1.03 * r) {
    111.                   rs[i] = 1.03 * r;
    112.                   directions[i] = -1 * directions[i];
    113.                 }
    114.                 const spX = rs[i] * Math.cos(angleSum);
    115.                 const spY = rs[i] * Math.sin(angleSum);
    116.                 sps.push({ x: spX, y: spY });
    117.                 for (let j = 0; j < 2; j++) {
    118.                   const cpAngleRand = unitAngle / 3;
    119.                   const cpR = rs[i] / Math.cos(cpAngleRand);
    120.                   const sign = j === 0 ? -1 : 1;
    121.                   const x = cpR * Math.cos(angleSum + sign * cpAngleRand);
    122.                   const y = cpR * Math.sin(angleSum + sign * cpAngleRand);
    123.                   cps.push({ x, y });
    124.                 }
    125.                 angleSum += unitAngle;
    126.               }
    127.               path.push(['M', sps[0].x, sps[0].y]);
    128.               for (let i = 1; i < spNum; i++) {
    129.                 path.push([
    130.                   'C',
    131.                   cps[2 * i - 1].x,
    132.                   cps[2 * i - 1].y,
    133.                   cps[2 * i].x,
    134.                   cps[2 * i].y,
    135.                   sps[i].x,
    136.                   sps[i].y,
    137.                 ]);
    138.               }
    139.               path.push(['C', cps[cpNum - 1].x, cps[cpNum - 1].y, cps[0].x, cps[0].y, sps[0].x, sps[0].y]);
    140.               path.push(['Z']);
    141.               return path;
    142.             },
    143.             // @ts-ignore
    144.             setState(name: string, value: number, item: any) {
    145.               const shape = item.get('keyShape');
    146.               if (name === 'dark') {
    147.                 if (value) {
    148.                   if (shape.attr('fill') !== '#fff') {
    149.                     shape.oriFill = shape.attr('fill');
    150.                     const uColor = unlightColorMap.get(shape.attr('fill'));
    151.                     shape.attr('fill', uColor);
    152.                   } else {
    153.                     shape.attr('opacity'0.2);
    154.                   }
    155.                 } else {
    156.                   if (shape.attr('fill') !== '#fff') {
    157.                     shape.attr('fill', shape.oriFill || shape.attr('fill'));
    158.                   } else {
    159.                     shape.attr('opacity'1);
    160.                   }
    161.                 }
    162.               }
    163.             },
    164.           },
    165.           'single-node',
    166.         );

    然后用g6的动画和渲染API来渲染出气泡图谱的动画效果和样式,即可。

    最后实现的效果如下:

    d361e5e11b7321916fc91fc18a7a33d2.png
    image.png

    效果演示

    在实现好这个小工具之后,我来带大家演示一下:

    10456cfd884fa74d2223de705f04e094.gif


    我们可以在右侧编辑修改数据,点击生成即可更新图谱。

    后期展望

    后续会持续优化它,来满足更多图表的支持,大家感兴趣的可以体验反馈~

    demo地址:http://wep.turntip.cn/design/bubbleMap

    e86c2850defe75511a75c5436c472f3d.png

    往期精彩

  • 相关阅读:
    编译vtk源码
    leetcode栈和队列三剑客
    算法-2.两数相加
    Linux内核基础 - list_splice_tail_init函数详解
    Java项目:ssm+mysql医药进销存系统
    leetcode146.LRU缓存,从算法题引入,全面学习LRU和链表哈希表知识
    注意了!这样用 systemd 可能会有风险
    Python入门第一部分
    企业电子杂志如何制作与分享
    深入理解JNINativeInterface函数<三>
  • 原文地址:https://blog.csdn.net/KlausLily/article/details/139942985