[React/JavaScript/Css]手搓时间轴鱼骨图

背景

本周需求要写一个包含时间轴鱼骨图和三个时间轴坐标的组件,需要包含以下功能:

  1. 鼠标滚动缩放时间轴:向上滚动时间轴间隔变大(稀疏),向下滚动时间轴间隔变小(密集)
  2. 初始化和点击按钮实现动画回放功能:点位顺序渐变排布到时间轴上

效果

由于没有找到相似的框架和样例代码,决定手搓。最终实现效果如下:

鱼骨图-动画回放和时间轴缩放

思路

1、整理好时间数据,并根据时间画时间轴。

const showData = [
  {
    time: "2024-10-01",
    // 对应轴显示属性(业务数据)
    actionup: {
      data: "2024-10-02", //日期时间
      release_post: "主贴发布:XXX事件", //主贴发布
      repost: "50次", // 转发
      comment: "50次" // 评论
    }
  }
];

2、时间轴分为一条线(base-line)和点位(degree-container),点位包括刻度(degree)、日期数字(num)和挂载在点位上的卡片。点位相对于线进行定位(上移,使刻度居于时间线上方),并逐步向右偏移相同的刻度(根据数据数量均分组件宽度)。此时就得到了一个带时间刻度的时间轴。

注意:(1)为了防止日期数字影响偏移,需要将其宽度置为0;(2)开始节点的left为0保证最左侧刻度居于时间线最左侧(顶格);(3)由于js计算的不准确性,均分时如果出现除不尽的情况可能会影响偏移的准确性。

3、将卡片相对于base-line进行定位,并将其下的圆圈、连线、三角形和卡片等通过 transform: translate(x, y) rotate(xdeg); 进行相对偏移。

4、通过添加鼠标滚轮事件修改缩放层级,时间轴放大transform: `scaleX(${scale})`,点位需同比缩小transform: `scaleX(${1 / scale})`,以保证时间刻度的大小不会变化。

5、动画效果通过将卡片元素透明度置成0,再对每个时间轴的卡片逐一将其透明度改为1,添加渐变效果transition: all ease-in-out 0.5s; ,实现每秒间隔打点的回放效果。

代码

  • index.js
import classNames from "classnames";
import { SurfaceIcon } from "sdata-icon";
import { getAllData } from "../../api/index.js";
import { graphInfo, actionField } from "./constant.js";
import {
  getStartAndEndTime,
  constructDailyDataFromTimestamps,
  getDay,
  getLeft,
  mergeAlternating
} from "./utils.js";
import "./index.less";

const { css } = await window.appSdk.components.emotion();
const { useEffect, useState, useRef } = window.React;
const { Popover, Button } = window.antd;

const Main = props => {
  console.log("主视图:", props);
  const { customConfig = {}, block } = props;
  const { baseConfig = {} } = block;
  const { width: componentWidth } = baseConfig;
  const {
    buttonBgColor = "#0ff2ff",
    buttonFontColor = "#FFF",
    upBgColor = "#ff5f2d",
    upFontColor = "#FFF",
    downBgColor = "#22bbe8",
    downFontColor = "#FFF",
    popoverBgColor = "#FFF",
    popoverFontColor = "#000"
  } = customConfig;

  const [showData, setShowData] = useState([]); // 按时间展示的数据顺序
  const [scale, setScale] = useState(1); // 缩放等级
  const scaleRef = useRef(1);

  useEffect(() => {
    init();
  }, []);

  // 初始化数据
  const init = async () => {
    const { data } = await getAllData(); // 接口请求数据
    const { startTime, endTime } = getStartAndEndTime(data); // 找到数据的开始和结束时间
    const useData = constructDailyDataFromTimestamps(startTime, endTime, data); // 将接口数据填充到对应时间
    setShowData(useData);
  };

  useEffect(() => {
    gradualchangeAll();
  }, [showData]);

  // 整体渐变
  const gradualchangeAll = () => {
    opacityChange(".card-container");
    gradualchangeForTop(".actionup-container", ".actiondown-container");
    gradualchange(".task-container");
    gradualchange(".report-container");
    gradualchange(".guarantee-container");
  };

  // 将全部元素的透明度置成0
  const opacityChange = async className => {
    const elements = document.querySelectorAll(className);
    for (let element of [...elements]) {
      element.style.opacity = 0;
    }
  };

  // 顶部时间线渐变
  const gradualchangeForTop = async (topClassName, bottomClassName) => {
    const topElements = document.querySelectorAll(topClassName);
    const bottomElements = document.querySelectorAll(bottomClassName);
    const result = mergeAlternating(topElements, bottomElements); // 交叉合并
    for (let element of [...result]) {
      await doAnimate(element);
    }
  };

  // 其余三条时间线渐变
  const gradualchange = async className => {
    const elements = document.querySelectorAll(className);
    for (let element of [...elements]) {
      await doAnimate(element);
    }
  };

  // 动画效果
  const doAnimate = element => {
    return new Promise(resolve => {
      setTimeout(() => {
        element.style.opacity = 1;
        resolve();
      }, 1000);
    });
  };

  useEffect(() => {
    props.updateProcess && props.updateProcess();
    const containerNode = document.querySelector(".Feat-20241010-022");

    // 缩放时间线
    containerNode.addEventListener("wheel", event => {
      event.preventDefault();
      event.stopPropagation();
      if (event.deltaY < 0) {
        scaleRef.current += 0.05;
        setScale(scaleRef.current);
      } else {
        if (scaleRef.current === 1) {
          setScale(1);
        } else {
          scaleRef.current -= 0.05;
          setScale(scaleRef.current);
        }
      }
    });
  }, []);

  // 上方卡片整体
  const actionContent = (type, data, bgColor, fontColor) => {
    return (
      <div
        className={`card-container ${type}-container`}
        style={{
          left: `${(componentWidth / 2 / showData.length) * scale}px`
        }}
      >
        <div
          className="tip-text"
          style={{
            background: bgColor,
            color: fontColor
          }}
        >
          {cardContent(type, data)}
        </div>
        <div className="line card-line" />
        <div className="circle" />
        <div className="triangle" />
      </div>
    );
  };

  // 图标整体
  const iconContent = (type, data, iconName) => {
    return (
      <div
        className={`card-container icon-container ${type}-container`}
        style={{
          left: `${(componentWidth / 2 / showData.length) * scale}px`
        }}
      >
        <div className="line icon-line" />
        <div className="circle" />
        <Popover
          overlayClassName={`${classNames(
            popoverCss
          )} Feat-20241010-022-popover`}
          placement="top"
          content={popoverContent(type, data)}
          trigger="click"
        >
          <SurfaceIcon
            className="icon"
            type={iconName}
            style={{ fontSize: 20, color: "#FFF" }}
          />
        </Popover>
      </div>
    );
  };

  // 图标的popover
  const popoverContent = (type, data) => {
    return <div>{cardContent(type, data)}</div>;
  };

  // 卡片内部字段显示
  const cardContent = (type, data) => {
    return actionField[type].map(item => {
      return (
        <div>
          {item.title}:{data[item.field]}
        </div>
      );
    });
  };

  // popover样式
  const popoverCss = css`
    .ant-popover-content{
      .ant-popover-inner{
         background-color:${popoverBgColor};
         .tip-text{
            color:${popoverFontColor}
         }
     
      }
    }
  }
`;

  return (
    <>
      {/*大屏定义外层容器百分百,不可删除*/}
      <div
        style={{ width: "100%", height: "100%", color: "#fff" }}
        className="Feat-20241010-022"
      >
        <Button
          onClick={gradualchangeAll}
          style={{ background: buttonBgColor, color: buttonFontColor }}
        >
          动画效果
        </Button>
        <div className="graph-container">
          {graphInfo.map(graphItem => {
            return (
              <div className={`graph graph${graphItem.index}`}>
                <div
                  className="base-line"
                  style={{ transform: `scaleX(${scale})` }}
                >
                  {showData.map((dataItem, index) => (
                    <div
                      className="degree-container"
                      style={{
                        transform: `scaleX(${1 / scale})`,
                        left: getLeft(showData.length, index),
                        zIndex: showData.length - index
                      }}
                    >
                      <div className="degree" />
                      <div className="num">{getDay(dataItem.time)}</div>
                      {graphItem.index === 1 &&
                        dataItem?.actionUp &&
                        actionContent(
                          "actionup",
                          dataItem?.actionUp,
                          upBgColor,
                          upFontColor
                        )}
                      {graphItem.index === 1 &&
                        dataItem?.actionDown &&
                        actionContent(
                          "actiondown",
                          dataItem?.actionDown,
                          downBgColor,
                          downFontColor
                        )}
                      {graphItem.index === 2 &&
                        dataItem?.task &&
                        iconContent(
                          graphItem.name,
                          dataItem?.task,
                          graphItem.iconName
                        )}
                      {graphItem.index === 3 &&
                        dataItem?.report &&
                        iconContent(
                          graphItem.name,
                          dataItem?.report,
                          graphItem.iconName
                        )}
                      {graphItem.index === 4 &&
                        dataItem?.guarantee &&
                        iconContent(
                          graphItem.name,
                          dataItem?.guarantee,
                          graphItem.iconName
                        )}
                    </div>
                  ))}
                </div>
              </div>
            );
          })}
        </div>
      </div>
    </>
  );
};

export default Main;

index.less

.Feat-20241010-022 {
  position: relative;
  display: flex;
  align-items: flex-end;
  overflow-x: auto;

  .ant-btn {
    position: absolute;
    top: 0px;
    right: 0px;
    border: 1px solid transparent;
  }

  .graph-container {
    width: 100%;

    .graph {
      .base-line {
        width: 100%;
        height: 2px;
        position: relative;
        transform-origin: left;

        background-color: #256470;

        .degree-container {
          transform-origin: left center;
          position: absolute;
          display: flex;
          flex-direction: column;
          align-items: center;
          top: -5px;
          color: #256470;

          // 时间线刻度
          .degree {
            width: 1px;
            height: 6px;
            background-color: #256470;
          }

          .num {
            width: 0px;
          }

          // 卡片或图标定位点
          .card-container {
            opacity: 0;
            transition: all ease-in-out 0.5s;
            position: absolute;
            bottom: 0px;
            left: 0px;
            width: 10px;
            height: 10px;

            // 卡片或图标连接线
            .line {
              width: 2px;
              background-color: #36abb5;
              transform-origin: bottom;
            }

            // 卡片连接线
            .card-line {
              height: 60px;
            }

            // 图标连接线
            .icon-line {
              height: 10px;
            }

            .circle {
              width: 6px;
              height: 6px;
              border-radius: 50%;
              background-color: #36abb5;
              box-shadow: 0 0 3px #ffffff;
            }

            .triangle {
              width: 0;
              height: 0;
              border-style: solid;
              border-width: 10px 8px;
            }

            .tip-text {
              z-index: 10;
              position: relative;

              padding: 5px;
              width: 240px;
              height: 100px;

              color: #fff;
              border-radius: 4px;
              box-shadow: 3px 3px 3px 0 rgba(0, 0, 0, 0.5);
              border: 1px solid rgb(255 243 243 / 0.3);
            }
          }

          .actionup-container {
            .tip-text {
              transform: translate(-47px, -149px);
            }

            .line {
              transform: translate(0px, -169px) rotate(57deg);
            }

            .circle {
              transform: translate(0px, -173px);
            }

            .triangle {
              transform: translate(42px, -215px);
              border-color: #36abb5 transparent transparent transparent;
            }
          }

          .actiondown-container {
            .tip-text {
              transform: translate(-47px, 32px);
            }

            .line {
              transform: translate(0px, -171px) rotate(125deg);
            }

            .circle {
              transform: translate(0px, -173px);
            }

            .triangle {
              transform: translate(42px, -154px);
              border-color: transparent transparent #36abb5 transparent;
            }
          }

          // 图标的圆点和线除了定位和角度不一样 其他均和第一条时间线一样
          .icon-container {
            .line {
              transform: translate(2px, -22px) rotate(0deg);
            }
            .circle {
              transform: translate(0px, -24px);
            }
            .icon {
              transform: translate(-7px, -58px);
            }
          }
        }
      }
    }

    .graph1 {
      height: 200px;
    }

    .graph2,
    .graph3,
    .graph4 {
      height: 60px;
    }
  }

  &::-webkit-scrollbar {
    display: none;
    width: 0px;
  }
}

.Feat-20241010-022-popover {
  .ant-popover-inner-content {
    width: 240px;
  }
}

canstant.js

export const graphInfo = [
  {
    index: 1,
    name: "action" // 信息发布
  },
  {
    index: 2,
    name: "task", // 调控
    iconName: "icon-icon-zhiliangbaogao"
  },
  {
    index: 3,
    name: "report", // 文书
    iconName: "icon-dingwei"
  },
  {
    index: 4,
    name: "guarantee", // 保障
    iconName: "icon-md-flash"
  }
];

export const actionField = {
  actionup: [
    {
      title: "主题发布",
      field: "release_post"
    },
    {
      title: "转发",
      field: "repost"
    },
    {
      title: "评论",
      field: "comment"
    }
  ],
  actiondown: [
    {
      title: "实际反馈",
      field: "actual_feedback"
    },
    {
      title: "状态",
      field: "status"
    },
    {
      title: "预期反馈",
      field: "expected_feedback"
    }
  ],
  task: [
    {
      title: "任务内容",
      field: "task_info"
    },

  ],
  report: [
    {
      title: "任务内容",
      field: "report_info"
    },

  ],
  guarantee: [
    {
      title: "保障内容",
      field: "guarantee_info"
    },

  ],
};

utils.js

const moment = window.moment;

// 获取刻度上的日期值
export const getDay = data => {
  const arr = data.split("-");
  return Number(arr[arr.length - 1]);
};

// 获取刻度相对左侧偏移量
export const getLeft = (length, index) => {
  const left = (100 / (length - 1)) * index;
  const leftValue = `calc(${left}% - 0px)`;
  return leftValue;
};

// 获取开始时间和结束时间
export const getStartAndEndTime = data => {
  const allDateTimes = [];
  Object.values(data).forEach(array => {
    array.forEach(item => {
      allDateTimes.push(item.date_time);
    });
  });

  const earliestDateTime = Math.min(...allDateTimes);
  const latestDateTime = Math.max(...allDateTimes);

  return {
    startTime: earliestDateTime,
    endTime: latestDateTime
  };
};

// 构造时间和数据的对应关系
export const constructDailyDataFromTimestamps = (
  earliestTimestamp,
  latestTimestamp,
  data
) => {
  // 将时间戳从秒转换为毫秒
  const startDate = new Date(earliestTimestamp);
  const endDate = new Date(latestTimestamp + 86400000 * 2); // 结束时间加一天

  const allTime = [];
  let currentDate = new Date(startDate);

  while (currentDate <= endDate) {
    allTime.push({
      time: currentDate.toISOString().split("T")[0]
    });
    currentDate.setDate(currentDate.getDate() + 1);
  }

  const {
    actionDown = [],
    actionUp = [],
    guarantee = [],
    report = [],
    task = []
  } = data;

  pushData(allTime, actionDown, "actionDown");
  pushData(allTime, actionUp, "actionUp");
  pushData(allTime, task, "task");
  pushData(allTime, report, "report");
  pushData(allTime, guarantee, "guarantee");

  return allTime;
};

const pushData = (allTime, data, name) => {
  data.forEach(item => {
    const curTime = allTime.find(
      t => t.time === moment(item.date_time).format("YYYY-MM-DD")
    );
    curTime[name] = item;
  });
};

// 交叉合并数组
export const mergeAlternating = (elements1, elements2) => {
  let result = [];
  let index1 = 0;
  let index2 = 0;

  while (index1 < elements1.length || index2 < elements2.length) {
    if (index1 < elements1.length) {
      result.push(elements1[index1]);
      index1++;
    }

    if (index2 < elements2.length) {
      result.push(elements2[index2]);
      index2++;
    }
  }

  return result;
};

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值