背景
本周需求要写一个包含时间轴鱼骨图和三个时间轴坐标的组件,需要包含以下功能:
- 鼠标滚动缩放时间轴:向上滚动时间轴间隔变大(稀疏),向下滚动时间轴间隔变小(密集)
- 初始化和点击按钮实现动画回放功能:点位顺序渐变排布到时间轴上
效果
由于没有找到相似的框架和样例代码,决定手搓。最终实现效果如下:
鱼骨图-动画回放和时间轴缩放
思路
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;
};