Canvas定制组件——事件线之动画和交互处理


theme: smartblue

项目背景

产品提了个需求,要看各种事件对某个指标的影响,要前端开发一个事件和折线图联动的组件,给了个设计稿大概就是上图的样子。

然后根据需求,使用Canvas定制了这样一个组件,并且开源出去,发布到npm上。

前面已经产出两篇关于事件线的博客:一篇介绍npm组件库项目搭建和文档编写;第二篇介绍了事件的基础绘制;感兴趣的朋友可以先阅读这前两篇。

今天这一篇主要讲事件线动画和交互,欢迎大家讨论;

动画交互分析

  • 鼠标拖动

    鼠标拖动,事件、折线跟随移动,就是从不同起点重新绘制内容区域;

  • 鼠标hover显示Tooltip

    检测鼠标移入,使用isPointInpath或者绘制区域和鼠标XY对比,控制现实内容和显示位置;

  • 点击事件显示开始时间结束时间

    和鼠标hover事件类似逻辑,只是换成点击事件监听

鼠标事件

根据以上分析,需要判断鼠标事件类型,需要获取鼠标XY坐标,同时对于滑动需要知道滑动距离,可以自定义一个hook,检测鼠标事件:

import { useEffect, useState } from 'react';

enum EMouseStatus {
  NONE = 'none',
  DRAG = 'drag',
  SCROLL_X = 'scroll_x',
  SCROLL_Y = 'scroll_y',
  HOVER = 'hover',
  CLICK = 'click',
  DOWN = 'down',
  UP = 'up',
}

// 停止事件冒泡
const stopPropagationAndDefault = (event: any) => {
  event.stopPropagation();
  event.cancelBubble = true;
  event.preventDefault();
  event.returnValue = false;
};
//记录上一次x轴滑动距离,用来优化事件频繁触发
let moveXLength = 0;

const useMouseMove = (selector: string, min: any, max: any) => {

  const [mouseState, setMouseState] = useState<any>({
    startX: 0,
    mouseMoveX: 0,
    mouseXY: {
      x: 0,
      y: 0,
    },
    mouseStatus: EMouseStatus.NONE,
  });

  const handleMouseDown = (event: any) => {
    const canvas = document.querySelector(selector);
    if (!canvas) {
      return;
    }
    setMouseState({
      ...mouseState,
      mouseStatus: EMouseStatus.DOWN,
      startX: moveXLength + event.clientX - canvas.getBoundingClientRect().left,
    });
  };

  const handleMouseMove = (event: any) => {
    const canvas = document.querySelector(selector);
    if (!canvas) {
      return;
    }
    const { startX, mouseStatus } = mouseState;
    if (startX === 0) {
      setMouseState({
        ...mouseState,
        mouseXY: {
          x: event.clientX - canvas.getBoundingClientRect().left,
          y: event.clientY - canvas.getBoundingClientRect().top,
        },
      });
    } else {
      const { mouseMoveX } = mouseState;
      let length = startX - (event.clientX - canvas.getBoundingClientRect().left);
      if (min && length < min) {
        length = min;
      }
      if (max && length > max) {
        length = max;
      }
      moveXLength = length;
      if (Math.abs(moveXLength - mouseMoveX) < 12) {
        return;
      }
      setMouseState({
        ...mouseState,
        mouseMoveX: moveXLength,
        mouseXY: undefined,
      });
    }
    if (mouseStatus !== EMouseStatus.DRAG && mouseStatus !== EMouseStatus.HOVER) {
      setMouseState({
        ...mouseState,
        mouseStatus: mouseStatus === EMouseStatus.DOWN ? EMouseStatus.DRAG : EMouseStatus.HOVER,
      });
    }
  };

  const handleMouseUp = () => {
    const { mouseStatus } = mouseState;
    setMouseState({
      ...mouseState,
      mouseStatus: mouseStatus === EMouseStatus.DOWN ? EMouseStatus.CLICK : EMouseStatus.NONE,
      startX: 0,
    });
  };

  const handleScroll = (event: any) => {
    //X轴滑动
    if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
      //停止事件冒泡和默认事件
      stopPropagationAndDefault(event);
      const { mouseMoveX } = mouseState;
      let length = moveXLength + event.deltaX;
      if (min && length < min) {
        length = min;
      }
      if (max && length > max) {
        length = max;
      }
      moveXLength = length;
      if (Math.abs(moveXLength - mouseMoveX) < 12) {
        return;
      }
      setMouseState({
        ...mouseState,
        mouseMoveX: moveXLength,
        mouseStatus: EMouseStatus.SCROLL_X,
      });
      return;
    }
  };

  useEffect(() => {
    const canvas = document.querySelector(selector);
    if (!canvas) {
      return;
    }
    canvas.addEventListener('mousedown', handleMouseDown);
    canvas.addEventListener('mousemove', handleMouseMove);
    canvas.addEventListener('mouseup', handleMouseUp);
    canvas.addEventListener('DOMMouseScroll', handleScroll, false);
    canvas.addEventListener('mousewheel', handleScroll, false);
    canvas.addEventListener('wheel', handleScroll, false);

    return () => {
      canvas.removeEventListener('mousedown', handleMouseDown);
      canvas.removeEventListener('mousemove', handleMouseMove);
      canvas.removeEventListener('mouseup', handleMouseUp);
      canvas.removeEventListener('DOMMouseScroll', handleScroll, false);
      canvas.removeEventListener('mousewheel', handleScroll, false);
      canvas.removeEventListener('wheel', handleScroll, false);
    };
  });
  return { ...mouseState };
};

export default useMouseMove;

useMouseMove 监听了鼠标事件落下,移动,抬起和滚轮(触摸板)事件,重点监听拖拽draghover,和滚轮(触摸板X轴方向),这里拖拽drag和hover事件是需要一定的判断:

  • 拖拽drag判断:鼠标落下mousedown——>鼠标移动mousemove,过程中鼠标未抬起mouseup;
  • hover判断:鼠标未落下mousedown,鼠标移动mousemove

所以drag和hover是互斥的,这里需要通过记录鼠标落下未抬起的状态,滚动(触摸板)事件则是通过累加deltaX获取;

动画重绘逻辑

事件和折线滑动动画的重绘逻辑比较简单,不断改变绘制的起点坐标,绘制逻辑不变就好了,这里还有优化空间,canvas分层,拆分不需要重绘部分等等,今天就咱不讨论了:

     const draw = (startX: number, startY: number, moveX: number) => {
      clearCanvas();
      // 画事件 不考虑事件和折线之间的间距
      drawEvents(paddingLeft + startX - moveX, startY + (eventTypeHeight - eventHeight) / 2);
      // 画折线 + 事件和折线之间的间距
      drawLines(paddingLeft + startX - moveX, startY + lineHeight + axisXheight);
      // 画X轴 Y轴 和 辅助线
      drawAxisAndLine(paddingLeft + startX, startY, moveX);
    };

    useEffect(() => {
      draw(startX, startY, mouseMoveX);
    }, [mouseMoveX, mouseXY, mouseStatus]);

鼠标hover的Tooltip事件

由于折线hover的时候可以不在折线上,所以isPointInPath就不能用,这里使用整个红框区域作为鼠标hover判断区域——中线左右2px,这样方便响应用户鼠标移入,因此事件和折线的判断逻辑保持一致,使用响应区域进行判断:

<img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/eb9dd0aa479544e5a6dd779aa2351c7c~tplv-k3u1fbpfcp-watermark.image?" alt="image.png" width="100%" />

Tooltip显示逻辑

定义showTooltip方法,根据类型,和检测区域判断是否显示tooltip,以及XY坐标(鼠标XY坐标稍微偏移一些即可),和Tooltip里数据内容

    const showTooltip = (
      type: ETooltipStatus,
      area: IArea,
      data: any,
      {
        dataType = '', // 左侧或右侧
        color = '#1890ff', // 颜色
        label = '',
        yField = '',
        key = '',
      }: any = {},
    ) => {
        // 重置逻辑
      if (
        mouseStatus === EMouseStatus.SCROLL_X ||
        mouseStatus === EMouseStatus.MOVE ||
        mouseStatus === EMouseStatus.DRAG ||
        (mouseXY && mouseXY.x < paddingLeft + eventTypeWidth) ||
        (mouseXY && mouseXY.x > canvasWidth - paddingRight) ||
        (mouseXY &&
          type === ETooltipStatus.LINE &&
          mouseXY.y > eventsHeight + axisXheight &&
          mouseXY.x > canvasWidth - paddingRight - line.axis.y.right.width)
      ) {
        linePointList = [];
        setLinePoint([]);
        setTooltipStatus(ETooltipStatus.NOTHING);
        setTooltipData(undefined);
        return false;
      }
      // 判断鼠标xy是否在检测区域内
      if (
        mouseXY &&
        mouseXY.x >= area.x &&
        mouseXY.x <= area.x + (area.w || 2) &&
        mouseXY.y >= area.y &&
        mouseXY.y <= area.y + (area.h || 2)
      ) {
        if (type === ETooltipStatus.LINE && !linePointList.find((ite: any) => ite?.key === key)) {
          linePointList.push({
            x: area?.pointX,
            y: area?.pointY,
            color,
            key,
            data,
            dataType,
            label,
            yField,
          });
          setLinePoint(linePointList); //重置操作
        }
        if (mouseStatus === EMouseStatus.HOVER) {
          setTooltipStatus(type);
          setTooltipData(data);
        }
        return true;
      } else if (
        tooltipStatus === type &&
        ((type === ETooltipStatus.LINE && tooltipData?.[line?.xField] === data?.[line?.xField]) ||
          (type === ETooltipStatus.EVENT &&
            tooltipData?.[event.fieldNames.key] === data?.[event.fieldNames.key]))
      ) {
        linePointList = [];
        setLinePoint([]);
        setTooltipStatus(ETooltipStatus.NOTHING);
        setTooltipData(undefined);
      }
      return false;
    };

这一块的逻辑简单,判断还是挺复杂的,特别是重置逻辑,调试了半天才能正常使用,大家需要注意。

Tooltip组件

前面拿到Tooltip坐标和数据,然后就是通过绝对定位把tooltip把div绘制在canvas上层,这里canvas和原生html标签联动

import React, { CSSProperties, ReactNode } from 'react';
import { ETooltipStatus } from '../../type';
import TooltipContent from './Content';
import './index.css';

interface IProps {
  location: any;
  canvasSize: any;
  pointLocation: any;
  fieldNames: {
    event: Record<string, any>;
    line: Record<string, any>;
  };
  label?: string;
  type: ETooltipStatus;
  data: Record<string, any>;
  customContent?: { style: CSSProperties; event?: ReactNode; line?: string[] | ReactNode };
  guideLineStyle: CSSProperties;
}
export default React.memo(
  ({
    location,
    canvasSize,
    pointLocation,
    fieldNames,
    type,
    guideLineStyle,
    customContent,
    data,
  }: IProps) => {
    if (!location || !data || type === ETooltipStatus.NOTHING) return null;
    const { width, height } = canvasSize;
    const tooRight = location?.x * 4 > width * 3;
    const tooTop = location?.y * 4 < height;
    const positionStyle = {
      [tooRight ? 'right' : 'left']: tooRight ? width - location?.x + 10 : location?.x + 10,
      [tooTop ? 'top' : 'bottom']: tooTop ? location?.y + 10 : height - location?.y + 10,
    };
    return (
      <>
        <div
          className="Tooltip"
          style={{
            ...positionStyle,
            ...(customContent?.style || {}),
          }}
        >
          <TooltipContent
            type={type}
            data={data}
            customContent={customContent}
            fieldNames={fieldNames}
            pointLocation={pointLocation.map(({ key, label, yField, data, color }: any) => ({
              key,
              label,
              yField,
              data,
              color,
            }))}
          />
        </div>
        {type === ETooltipStatus.LINE && pointLocation?.length > 0 && (
          <>
            {pointLocation?.map(({ x, y, color = '#1890ff' }: any, index: number) => (
              <span key={index + '_' + x + '_' + y}>
                <span className="tooltipLine" style={{ left: x - 1, ...guideLineStyle }} />
                <span
                  className="linePoint"
                  style={{ background: color, left: x - 4, top: y - 4 }}
                />
              </span>
            ))}
          </>
        )}
      </>
    );
  },
  (prev, next) => {
    // return false 更新,true不更新
    return !(
      Math.abs((prev.location?.x || 0) - next.location?.x.toFixed(0)) >= 4 ||
      Math.abs((prev.location?.y || 0) - next.location?.y.toFixed(0)) >= 4
    );
  },
);

该针对多折线,所以使用pointLocation收集所有响应hover的折线点位、数据、以及相关style,进行动态定位显示;再有就是事件和折线的tooltip逻辑有些差异,需要区别对待。

写在后面

动画和交互的逻辑主要是事件处理和重绘操作,总的逻辑比较清晰,期间有很多细节需要在开发时停下来想清楚再开发,不然很容易发现扩展性问题,搞不好就要推倒重来,不说在功能开发上推倒重来,仅仅是在API配置项提取和分类就大改了3版,可谓是耗时耗力。

作者:格心派 原文地址:https://juejin.cn/post/7145862377865969671

%s 个评论

要回复文章请先登录注册