数据可视化大屏设计器开发-多选拖拽

3,523 阅读6分钟

数据可视化大屏设计器开发-多选拖拽

开头

本文是数据可视化开始的开发细节第五章。关于画布中的元素的各种鼠标拖拽操作。

简单声明
本人只是一个菜鸡,以下方法仅个人思路,如有错误,轻喷🙏🏻 。

开头说明
下面所说的元素表示的是组或者组件的简称。

开始

大屏设计当中,不乏需要调整图表组件的位置尺寸
相较于网页低代码,图表大屏低代码可能需要更复杂的操作,比如嵌套成组多选单元素拖拽缩放多元素拖拽缩放
并且需要针对鼠标的动作做相应的区分,当中包含了相当的细节,这里就一一做相应的讲解。

涉及的依赖

  • react-rnd
    react-rnd是一个包含了拖拽和缩放两个功能的react组件,并且有非常丰富的配置项。
    内部是依赖了拖拽(react-draggable)和缩放(re-resizable)两个模块。
    奈何它并没有内置多元素的响应操作,本文就是针对它来实现对应的操作。
  • react-selecto
    react-selecto是一个简单的简单易用的多选元素组件。
  • eventemitter3
    eventemitter3是一个自定义事件模块,能够在任何地方触发和响应自定义的事件,非常的方便。

相关操作

多选

画布当中可以通过鼠标点击拖拽形成选区,选区内的元素即是被选中的状态。

这里即可以使用react-selecto来实现此功能。

selecto-demo.gif

从图上操作可以看到,在选区内的元素即被选中(会出现黑色边框)。

  import ReactSelecto from 'react-selecto';

  const Selecto = () => {

    return (
      <ReactSelecto
        // 会被选中元素的父容器 只有这个容器里的元素才会被选中  
        dragContainer={'#container'}
        // 被选择的元素的query 
        selectableTargets={['.react-select-to']}
        // 表示元素有被选中的百分比为多少时才能被选中
        hitRate={10}
        // 当已经存在选中项时按住指定按键可进行继续选择  
        toggleContinueSelect={'shift'}
        // 可以通过点击选择元素
        selectByClick
        // 是否从内部开始选择(?)
        selectFromInside
        // 拖拽的速率不知道是不是这个意思ratio={0}
        // 选择结束
        onSelectEnd={handleSelectEnd}
      ></ReactSelecto>
    );
  };

这里有几个需要注意的地方。

  • 操作互斥
    画布当中的多选和拖拽都是通过鼠标左键来完成的,所以当一个元素是被选中的时候,鼠标想从元素上开始拖拽选择组件是不被允许的,此时应该是拖拽元素,而不是多选元素。

而元素如果没有被选中时,上面的操作则变成了多选。

rnd-select.gif

  • 内部选中
    画布当中有的概念,它是一个组与组件无限嵌套的结构,并且可以单独选中组内的元素。
    当选中的是组内的元素时,即说明最外层的组是被选中的状态,同样需要考虑上面所说的互斥问题。

单元素拖拽缩放

单元素操作相对简单,只需要简单使用react-rnd提供的功能即可完成。

single-rnd.gif

多元素拖拽缩放

这里就是本文的重点了,结合前面介绍的几个依赖,实现一个简单的多选拖拽缩放的功能。

具体思路

多个元素拖拽,说到底其实鼠标拖拽的还是一个元素,就是鼠标拖动的那一个元素。
其余被选中的元素,仅仅需要根据被拖动的元素的尺寸位置变动来做相应的加减处理即可。

相关问题
  • 信息计算
    联动元素的位置尺寸信息该如何计算。
  • 组件间通信
    因为每一个图表组件并非是单纯的同级关系,如果是通过层层props传递,免不了会有多余的刷新,造成性能问题。
    而通过全局的dva状态同样在更新的时候会让组件刷新。
  • 数据刷新
    图表数据是来自于dva全局的数据,现在频繁自刷新相当于是一直更新全局的数据,同样会造成性能问题。
  • 其他
    一些细节问题

解决方法

  • 信息计算
    关于位置的计算相对简单,只需要单纯的将操作的元素的位置和尺寸差值传递给联动组件即可。
  • 组件间通信
    根据上面问题的解析,可以使用eventemitter3来完成任意位置、层级的数据通信,并且它和react渲染无任何关系。
import { useCallback, useEffect } from 'react'
import EventEmitter from 'eventemitter3'

const eventemitter = new EventEmitter()

const SonA = () => {

  console.log('刷新')

  useEffect(() => {
    const listener = (value) => {
      console.log(value)
    }
    eventemitter.addListener('change', listener)
    return () => {
      eventemitter.removeListener('change', listener)
    }
  }, [])

  return (
    <span>son A</span>
  )

}

const SonB = () => {

  const handleClick = useCallback(() => {
    eventemitter.emit('change', 'son B')
  }, [])

  return (
    <span>
      <button onClick={handleClick}>son B</button>
    </span>
  )

}

const Parent = () => {

  return (
    <div>
      <SonA />
      <br />
      <SonB />
    </div>
  )

}

运行上面的例子可以发现,点击SonB组件的按钮,可以让SonA接收到来自其的数据,并且并没有触发SonA的刷新。
需要接收数据的组件只需要监听(addListener)指定的事件即可,比如上面的change事件。
而需要发送数据的组件则直接发布(emit)事件即可。
这样就避免了一些不必要的刷新。
eventemitter3.gif

  • 数据刷新
    频繁刷新全局数据,会导致所有依赖其数据的组件都会刷新,所以考虑为需要刷新数据的组件在内部单独维护一份状态。
    开始操作时,记录下状态,标识开始使用内部状态表示图表的信息,结束操作时处理下内部数据状态,将数据更新到全局中去。
  import { useMemo, useEffect, useState, useRef } from 'react'
  import EventEmitter from 'eventemitter3'

  const eventemitter = new EventEmitter()

  const Component = (props: {
    position: {left: number, top: number}
  }) => {

    const [ position, setPosition ] = useState({
      left: 0,
      top: 0
    })

    const isDrag = useRef(false)

    const dragStart = () => {
      isDrag.current = true 
      setPosition(props.position)
    }

    const drag = (position) => {
      setPosition(position)
    }

    const dragEnd = () => {
      isDrag.current = false 
      // TODO 
      // 更新数据到全局
    }

    useEffect(() => {
      eventemitter.addListener('dragStart', dragStart)
      eventemitter.addListener('drag', drag)
      eventemitter.addListener('dragEnd', dragEnd)
      return () => {
        eventemitter.removeListener('dragStart', dragStart)
        eventemitter.removeListener('drag', drag)
        eventemitter.removeListener('dragEnd', dragEnd)
      }
    }, [])

    return (
      <span
        style={{
          left: (isDrag.current ? position : props.position).left,
          top: (isDrag.current ? position : props.position).top
        }}
      >图表组件</span>
    )

  }


上面的数据更新还可以更加优化,对于短时间多次更新操作,可以控制一下更新频率,将多次更新合并为一次。

  • 其他
    • 控制刷新
      这里的控制刷新指的是上述的内部刷新,不需要每次都响应react-rnd发出的相关事件,可以做对应的节流(throttle)操作,减少事件触发频率。
    • 通信冲突问题
      因为所有的组件都需要监听拖拽的事件,包括当前被拖拽的组件,所以在传递信息时,需要把自身的id类似值传递,防止冲突。
    • 组件的缩放属性
      这里是关于前文说到的成组的逻辑相关,因为组存在scaleXscaleY两个属性,所以在调整大小的时候,也要兼顾此属性(本文先暂时不考虑这个问题)。
    • 单元素选中情况
      自定义事件的监听是无差别的,当只选中了一个元素进行拖拽缩放操作时,无须触发相应的事件。

最后的DEMO

final-demo.gif

成品

其实在之前就已经发现其实react-selecto的作者也有研发其他的可视化操作模块,包括本文所说的多选拖拽的操作,但是奈何无法满足本项目的需求,故自己实现了功能。
如果有兴趣可以去看一下这个成品moveable

总结

通过上面的思路,即可完成一个简单的多元素拖拽缩放的功能,其核心其实就是eventemitter3的自定义事件功能,它的用途在平常的业务中非常广泛。
比如我们完全可以在以上例子的基础上,加上元素拖拽时吸附的功能。

结束

结束🔚。

顺便在下面附上相关的链接。

试用地址
试用账号
静态版试用地址
操作文档
代码地址