一个响应式时间轴功能
效果图:
Timeline.vue组件
template:
<section id="timeline" class="timeline bs-timeline">
<div class="prev" @click="handlePrev"><</div>
<div class="line"></div>
<div class="time-main">
<div class="time-content">
<div v-for="(item,index) in timeList"
:key="new Date(item.Date).getTime()"
:data-date="item.Date"
:data-index="index"
:style="'margin-left:' + marginLeft + 'px'"
:class="item.className"
v-html="item.contentText">
</div>
</div>
</div>
<div class="next" @click="handleNext">></div>
</section>
script:
主要使用 ResizeObserver API, ResizeObserver 是浏览器提供的一种新的 API,用于监听一个元素内容区域或盒模型大小的变化,当元素大小发生变化时会触发回调函数。
// ResizeObserver 使用示例代码:
const ro = new ResizeObserver(entries => {
for (let entry of entries) {
// 通过 contentRect 属性获取元素的新尺寸
const { width, height } = entry.contentRect;
console.log(`Size changed to ${width} x ${height}`);
}
});
ro.observe(document.body);
在老旧浏览器上使用 ResizeObserver,则可以借助 polyfill 实现兼容,如resize-observer-polyfill 库。
其中script代码
<script>
export default {
name: "TimeLine",
data() {
return {
dataList: [
{
Code: "hospital",
Text: "(☛)",
Date: "2023-04-10"
},
{
Code: "hospital",
Text: "(☚)",
Date: "2023-04-15"
}
],
// 时间轴日期列表
timeList: [{ Date: "2022-10-10 10:10:10" }, { Date: "2022-10-04" }],
// 当前日期
curDate: new Date(),
dayNum: 1,
marginLeft: 5,
color: {
outpatient: "rgba(255,154,38,1)",
hospital: "rgba(255,61,64,1)"
},
end_date: "", // 截止日期
first_date: "", // 开始日期
max_date: "", // 最大日期
min_date: "", // 最小日期
resizeObserver: null,
};
},
mounted() {
let me = this,
el = document.querySelector("#timeline");
me.mainEl = el.querySelector(".time-main");
me.prevEl = el.querySelector(".prev");
me.nextEL = el.querySelector(".next");
me.renderTimeline();
},
beforeDestroy() {
let me = this;
me.resizeObserver.unobserve(me.mainEl);
},
methods: {
handlePrev(e) {
let me = this;
if (me.compareTwoDate(me.first_date, me.min_date) <= 0) {
e.target.classList.remove("active");
return;
}
me.curDate = me.getNeighbDate(me.curDate, -1);
me.renderTimeline();
},
handleNext(e) {
let me = this;
if (me.compareTwoDate(me.end_date, me.max_date) >= 0) {
e.target.classList.remove("active");
return;
}
me.curDate = me.getNeighbDate(me.curDate, +1);
me.renderTimeline();
},
// 创建渲染时间轴
renderTimeline() {
let me = this;
me.dataList = me.dateReverseOrder(me.dataList, "Date");
if (me.dataList.length <= 0) {
me.contentResize();
return;
}
let len = me.dataList.length;
me.max_date = me.formatDate(me.getNMonth(new Date(), 1));
// 计算传入数据中日期最小的一天
me.min_date = me.formatDate(me.getNMonth(me.dataList[len - 1].Date, 0));
me.contentResize();
},
// 监听时间轴元素对象resize
contentResize: function() {
let me = this;
me.calcContent();
me.resizeObserver = new ResizeObserver(me.handleResize)
me.resizeObserver.observe(me.mainEl)
},
handleResize: function() {
let me = this;
me.throttle(me.calcContent());
},
// 计算
calcContent: function() {
let me = this;
let content_width = me.mainEl.clientWidth;
let mon_width = 54 + 30 * me.marginLeft;
let r = Math.floor(content_width / mon_width);
me.dayNum = r - 1;
me.render_func();
},
render_func() {
let me = this;
let date = new Date(me.curDate);
// 截止到当前日期的下个月
me.end_date = me.formatDate(me.getNMonth(date, +1));
// 开始日期当月
me.first_date = me.formatDate(me.getNMonth(date, -me.dayNum));
me.hasArrowActive();
let result = me.getBetweenDateStr(me.first_date, me.end_date);
me.timeList = result;
me.renderTag(result);
},
// 节流
throttle: function(fn) {
let t1 = 0;
return function() {
let t2 = new Date();
if (t2 - t1 > 2000) {
fn.apply(this, arguments);
t1 = t2;
}
};
},
renderTag(result) {
let me = this;
result.forEach(item => {
let curr_date = item.Date;
let tmp_month = me.getMonth(curr_date);
let date_day = curr_date.slice(8, 10);
item.className = "axis";
item.contentText = "";
// 1号
if (date_day === "01") {
if (tmp_month === 1 || tmp_month === 6) {
let tmp_year = me.getYear(curr_date);
tmp_month = tmp_year + "年" + tmp_month;
}
item.className = "circleAxis";
item.contentText = `<div class="fontAxis boldFont">${tmp_month}月</div>`;
}
// 10号 20号
if (date_day === "10" || date_day === "20") {
item.className = "axis boldAxis";
item.contentText = `<div class="fontAxis">${date_day}</div>`;
}
// 判断传入的数据是否可视时间轴内
let curDataItem = me.filterData(curr_date);
if (curDataItem && Object.keys(curDataItem).length > 0) {
item.className = "axis axisHeight";
let color = me.color[curDataItem.Code],
bgColor = color.replace(",1)", ",0.3)");
item.contentText += `<div class="bubbleAxis"
data-id="${curr_date}"
style="background-color: ${color}">
<div class="bubbleAxis-inner">${date_day}</div>
</div>`;
item.contentText += `<div class="bubbleText" data-id="${curDataItem.Text}"
style="color: ${color}; background-color: ${bgColor};" >
${curDataItem.Text}</div>`;
}
});
},
// 添加上下按钮添加active状态
hasArrowActive: function() {
let me = this;
if (me.compareTwoDate(me.first_date, me.min_date) > 0) {
me.prevEl.classList.add("active");
}
if (me.compareTwoDate(me.end_date, me.max_date) < 0) {
me.nextEL.classList.add("active");
}
},
// 获取指定日期的上下n月的日期
getNeighbDate: function(date, n) {
let nowdate = new Date(date);
nowdate.setMonth(nowdate.getMonth() + n);
let y = nowdate.getFullYear();
let m = nowdate.getMonth() + 1;
let d = nowdate.getDate();
return y + "-" + m + "-" + d;
},
// 传入的对象数组根据日期倒叙
dateReverseOrder: function(data, type) {
return data.sort(function(a, b) {
return a[type] < b[type] ? 1 : -1;
});
},
// 比较两个日期的大小
compareTwoDate: function(date1, date2) {
let date1_s = new Date(date1).toLocaleDateString();
let date2_s = new Date(date2).toLocaleDateString();
return Date.parse(date1_s) - Date.parse(date2_s);
},
getMonth: function(date) {
date = new Date(date);
return date.getMonth() + 1;
},
getYear: function(date) {
date = new Date(date);
return date.getFullYear();
},
// 过滤数据
filterData: function(param) {
let me = this;
let idx = me.dataList.findIndex(function(e) {
return me.isSameDay(e.Date, param);
});
let result = me.dataList[idx];
return result;
},
// 判断两个日期是否是同一天
isSameDay: function(start, end) {
let startMs = new Date(start).setHours(0, 0, 0, 0);
let endMs = new Date(end).setHours(0, 0, 0, 0);
return startMs === endMs;
},
// 获取当前日期的上、下n个月的第一天
getNMonth: function(date, n) {
date = new Date(date);
return new Date(date.getFullYear(), date.getMonth() + n, 1);
},
// 生成两个日期之间每天的日期的对象数组
getBetweenDateStr(start, end) {
let me = this;
if (me.isSameDay(start, end)) {
return [{ Date: start }];
}
let result = [];
let beginDay = start.split("-");
let endDay = end.split("-");
let diffDay = new Date();
let dateList = new Array();
let i = 0;
diffDay.setDate(beginDay[2]);
diffDay.setMonth(beginDay[1] - 1);
diffDay.setFullYear(beginDay[0]);
result.push({
Date: start
});
while (i == 0) {
let countDay = diffDay.getTime() + 24 * 60 * 60 * 1000;
diffDay.setTime(countDay);
dateList[2] = diffDay.getDate();
dateList[1] = diffDay.getMonth() + 1;
dateList[0] = diffDay.getFullYear();
if (String(dateList[1]).length == 1) {
dateList[1] = "0" + dateList[1];
}
if (String(dateList[2]).length == 1) {
dateList[2] = "0" + dateList[2];
}
result.push({
Date: dateList[0] + "-" + dateList[1] + "-" + dateList[2]
});
if (
dateList[0] == endDay[0] &&
dateList[1] == endDay[1] &&
dateList[2] == endDay[2]
) {
i = 1;
}
}
return result;
},
// 格式化日期为xxxx-yy-mm
formatDate: function(date) {
let fmtNum = function(n) {
n = n.toString();
return n[1] ? n : "0" + n;
};
if (date) {
date = new Date(date);
let year = date.getFullYear();
let month = date.getMonth() + 1;
let day = date.getDate();
return [year, month, day].map(fmtNum).join("-");
}
}
}
};
</script>
style:
vue中动态字符串模板中的样式
可以通过给样式添加 /deep/、>>> 或者 ::v-deep 等关键字,来强制样式穿透到子组件中去
<style scoped>
.bs-timeline {
width: 100%;
height: 100%;
position: relative;
}
.bs-timeline .prev {
left: 10px;
}
.bs-timeline .next {
right: 10px;
}
.bs-timeline .prev,
.bs-timeline .next,
.bs-timeline .line,
.bs-timeline .time-main {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.bs-timeline .prev,
.bs-timeline .next {
width: 20px;
height: 20px;
line-height: 20px;
border-radius: 5px;
color: #bbb;
background-color: #fff;
text-align: center;
box-shadow: 0px 0px 3px #d8d3d3;
cursor: pointer;
user-select: none;
}
.bs-timeline .prev.active,
.bs-timeline .next.active {
color: #0d99e4;
transform: scale(1.1) translateY(-50%);
}
.bs-timeline /deep/ .line {
height: 1px;
width: calc(100% - 60px);
background-color: #cae8f7;
left: 30px;
}
.bs-timeline /deep/ .time-main {
width: calc(100% - 70px);
height: 80px;
margin: 0 auto;
left: 35px;
}
.bs-timeline /deep/ .time-content {
width: 100%;
height: 100%;
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
}
.bs-timeline /deep/ .axis {
height: 20px;
margin-top: 30px;
border-right: 1px solid #cae8f7;
position: relative;
font-size: 14px;
}
.bs-timeline /deep/ .axisHeight {
height: 50px !important;
}
.bs-timeline /deep/ .circleAxis {
width: 4px !important;
height: 4px !important;
margin-top: 34px;
border-radius: 50%;
border: 4px solid #0d99e4;
position: relative;
box-sizing: content-box;
}
.bs-timeline /deep/ .circleAxis .fontAxis {
top: 12px !important;
}
.bs-timeline /deep/ .boldFont {
font-weight: bold;
}
.bs-timeline /deep/ .fontAxis {
position: absolute;
font-size: 12px;
white-space: nowrap;
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
}
.bs-timeline /deep/ .boldAxis {
border-right: 2px solid #cae8f7 !important;
}
.bs-timeline /deep/ .bubbleAxis {
position: absolute;
top: -15px;
width: 18px;
height: 18px;
text-align: center;
background: rgb(247, 84, 9);
border-radius: 50% 50% 2% 50%;
transform: rotate(45deg) translateX(-65%);
left: 50%;
cursor: pointer;
}
.bs-timeline /deep/ .bubbleAxis .bubbleAxis-inner {
color: #ffffff;
transform: rotate(-45deg);
}
.bs-timeline /deep/ .bubbleText {
padding: 0 2px;
position: absolute;
bottom: -20px;
transform: translateX(-35%);
left: 50%;
cursor: pointer;
text-align: center;
border-radius: 5px;
white-space: nowrap;
pointer-events: none;
}
.bs-timeline .prev:hover,
.bs-timeline .next:hover {
transform: scale(1.1) translateY(-50%) scale(1.1);
box-shadow: #333 0px 0px 3px;
}
.bs-timeline /deep/ .axisHeight:hover {
height: 68px !important;
transform: translateX(-65%);
z-index: 9;
}
.bs-timeline .prev,
.bs-timeline .next,
.bs-timeline .time-main,
.bs-timeline .line {
margin-top: -10px;
}
</style>
优化后的代码:
<template>
<section id="timeline" class="timeline bs-timeline">
<div class="prev" @click="handlePrev"><</div>
<div class="line"></div>
<div class="time-main">
<div class="time-content">
<div
v-for="(item, index) in timeList"
:key="new Date(item.Date).getTime()"
:data-date="item.Date"
:data-index="index"
:style="'margin-left:' + marginLeft + 'px'"
:class="item.className"
v-html="item.contentText"></div>
</div>
</div>
<div class="next" @click="handleNext">></div>
</section>
</template>
<script>
import './TimelineResize';
export default {
name: 'TimeLine',
data() {
return {
dataList: [
{
Code: 'hospital',
Text: '(☛)',
Date: '2023-04-10'
},
{
Code: 'hospital',
Text: '(☚)',
Date: '2023-04-15'
}
],
// 时间轴日期列表
timeList: [{Date: '2022-10-10 10:10:10'}, {Date: '2022-10-04'}],
// 当前日期
curDate: new Date(),
dayNum: 1,
marginLeft: 5,
color: {
outpatient: 'rgba(255,154,38,1)',
hospital: 'rgba(255,61,64,1)'
},
endDate: '',
firstDate: '',
maxDate: '',
minDate: '',
resizeObserver: null
};
},
mounted() {
let me = this;
console.log('this.$el--', this.$el);
let el = this.$el;
me.mainEl = el.querySelector('.time-main');
me.prevEl = el.querySelector('.prev');
me.nextEL = el.querySelector('.next');
me.renderTimeline();
},
beforeDestroy() {
let me = this;
me.resizeObserver.unobserve(me.mainEl);
},
methods: {
handlePrev(e) {
let me = this;
if (me.compareTwoDate(me.firstDate, me.minDate) <= 0) {
e.target.classList.remove('active');
return;
}
me.curDate = me.getNeighbDate(me.curDate, -1);
me.renderTimeline();
},
handleNext(e) {
let me = this;
if (me.compareTwoDate(me.endDate, me.maxDate) >= 0) {
e.target.classList.remove('active');
return;
}
me.curDate = me.getNeighbDate(me.curDate, +1);
me.renderTimeline();
},
// 创建渲染时间轴
renderTimeline() {
let me = this;
me.dataList = me.dateReverseOrder(me.dataList, 'Date');
if (me.dataList.length <= 0) {
me.contentResize();
return;
}
let len = me.dataList.length;
me.maxDate = me.formatDate(me.getNMonth(new Date(), 1));
// 计算传入数据中日期最小的一天
me.minDate = me.formatDate(me.getNMonth(me.dataList[len - 1].Date, 0));
me.contentResize();
},
// 监听时间轴元素对象resize
contentResize() {
let me = this;
me.calcContent();
me.resizeObserver = new ResizeObserver(me.handleResize);
me.resizeObserver.observe(me.mainEl);
},
handleResize() {
let me = this;
me.throttle(me.calcContent());
},
// 计算
calcContent() {
let me = this;
let contentWidth = me.mainEl.clientWidth;
let monWidth = 54 + 30 * me.marginLeft;
let r = Math.floor(contentWidth / monWidth);
me.dayNum = r - 1;
me.renderFunc();
},
renderFunc() {
let me = this;
let date = new Date(me.curDate);
// 截止到当前日期的下个月
me.endDate = me.formatDate(me.getNMonth(date, +1));
// 开始日期当月
me.firstDate = me.formatDate(me.getNMonth(date, -me.dayNum));
me.hasArrowActive();
let result = me.getBetweenDateStr(me.firstDate, me.endDate);
me.timeList = result;
me.renderTag(result);
},
// 节流
throttle(fn) {
let t1 = 0;
// eslint-disable-next-line space-before-function-paren
return function () {
let t2 = new Date();
if (t2 - t1 > 2000) {
// eslint-disable-next-line prefer-rest-params
fn.apply(this, arguments);
t1 = t2;
}
};
},
renderTag(result) {
let me = this;
result.forEach(item => {
let currDate = item.Date;
let tmpMonth = me.getMonth(currDate);
let dateDay = currDate.slice(8, 10);
item.className = 'axis';
item.contentText = '';
// 1号
if (dateDay === '01') {
if (tmpMonth === 1 || tmpMonth === 6) {
let tmpYear = me.getYear(currDate);
tmpMonth = tmpYear + '年' + tmpMonth;
}
item.className = 'circleAxis';
item.contentText = `<div class="fontAxis boldFont">${tmpMonth}月</div>`;
}
// 10号 20号
if (dateDay === '10' || dateDay === '20') {
item.className = 'axis boldAxis';
item.contentText = `<div class="fontAxis">${dateDay}</div>`;
}
// 判断传入的数据是否可视时间轴内
let curDataItem = me.filterData(currDate);
if (curDataItem && Object.keys(curDataItem).length > 0) {
item.className = 'axis axisHeight';
let color = me.color[curDataItem.Code];
let bgColor = color.replace(',1)', ',0.3)');
item.contentText += `<div class="bubbleAxis"
data-id="${currDate}"
style="background-color: ${color}">
<div class="bubbleAxis-inner">${dateDay}</div>
</div>`;
item.contentText += `<div class="bubbleText" data-id="${curDataItem.Text}"
style="color: ${color}; background-color: ${bgColor};" >
${curDataItem.Text}</div>`;
}
});
},
// 添加上下按钮添加active状态
hasArrowActive() {
let me = this;
if (me.compareTwoDate(me.firstDate, me.minDate) > 0) {
me.prevEl.classList.add('active');
}
if (me.compareTwoDate(me.endDate, me.maxDate) < 0) {
me.nextEL.classList.add('active');
}
},
// 获取指定日期的上下n月的日期
getNeighbDate(date, n) {
let nowdate = new Date(date);
nowdate.setMonth(nowdate.getMonth() + n);
let y = nowdate.getFullYear();
let m = nowdate.getMonth() + 1;
let d = nowdate.getDate();
return y + '-' + m + '-' + d;
},
// 传入的对象数组根据日期倒叙
dateReverseOrder(data, type) {
return data.sort((a, b) => (a[type] < b[type] ? 1 : -1));
},
// 比较两个日期的大小
compareTwoDate(date1, date2) {
let date1S = new Date(date1).toLocaleDateString();
let date2S = new Date(date2).toLocaleDateString();
return Date.parse(date1S) - Date.parse(date2S);
},
getMonth(date) {
date = new Date(date);
return date.getMonth() + 1;
},
getYear(date) {
date = new Date(date);
return date.getFullYear();
},
// 过滤数据
filterData(param) {
let me = this;
let idx = me.dataList.findIndex(e => me.isSameDay(e.Date, param));
let result = me.dataList[idx];
return result;
},
// 判断两个日期是否是同一天
isSameDay(start, end) {
let startMs = new Date(start).setHours(0, 0, 0, 0);
let endMs = new Date(end).setHours(0, 0, 0, 0);
return startMs === endMs;
},
// 获取当前日期的上、下n个月的第一天
getNMonth(date, n) {
date = new Date(date);
return new Date(date.getFullYear(), date.getMonth() + n, 1);
},
// 生成两个日期之间每天的日期的对象数组
getBetweenDateStr(startDate, endDate) {
let start = new Date(startDate);
let end = new Date(endDate);
for (var arr = [], dt = new Date(start); dt <= end; dt.setDate(dt.getDate() + 1)) {
arr.push({
Date: this.formatDate(new Date(dt))
});
}
return arr;
},
// 格式化日期为xxxx-yy-mm
formatDate(date) {
// eslint-disable-next-line space-before-function-paren
let fmtNum = function (n) {
n = n.toString();
return n[1] ? n : '0' + n;
};
if (date) {
date = new Date(date);
let year = date.getFullYear();
let month = date.getMonth() + 1;
let day = date.getDate();
return [year, month, day].map(fmtNum).join('-');
}
}
}
};
</script>
<style lang="scss" scoped>
.bs-timeline {
width: 100%;
height: 100%;
position: relative;
}
.bs-timeline .prev {
left: 10px;
}
.bs-timeline .next {
right: 10px;
}
.bs-timeline .prev,
.bs-timeline .next,
.bs-timeline .line,
.bs-timeline .time-main {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.bs-timeline .prev,
.bs-timeline .next {
width: 20px;
height: 20px;
line-height: 20px;
border-radius: 5px;
color: #bbb;
background-color: #fff;
text-align: center;
box-shadow: 0px 0px 3px #d8d3d3;
cursor: pointer;
user-select: none;
}
.bs-timeline .prev.active,
.bs-timeline .next.active {
color: #0d99e4;
transform: scale(1.1) translateY(-50%);
}
.bs-timeline ::v-deep .line {
height: 1px;
width: calc(100% - 60px);
background-color: #cae8f7;
left: 30px;
}
.bs-timeline ::v-deep .time-main {
width: calc(100% - 70px);
height: 80px;
margin: 0 auto;
left: 35px;
}
.bs-timeline ::v-deep .time-content {
width: 100%;
height: 100%;
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
}
.bs-timeline ::v-deep .axis {
height: 20px;
margin-top: 30px;
border-right: 1px solid #cae8f7;
position: relative;
font-size: 14px;
}
.bs-timeline ::v-deep .axisHeight {
height: 50px !important;
}
.bs-timeline ::v-deep .circleAxis {
width: 4px !important;
height: 4px !important;
margin-top: 34px;
border-radius: 50%;
border: 4px solid #0d99e4;
position: relative;
box-sizing: content-box;
}
.bs-timeline ::v-deep .circleAxis .fontAxis {
top: 12px !important;
}
.bs-timeline ::v-deep .boldFont {
font-weight: bold;
}
.bs-timeline ::v-deep .fontAxis {
position: absolute;
font-size: 12px;
white-space: nowrap;
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
}
.bs-timeline ::v-deep .boldAxis {
border-right: 2px solid #cae8f7 !important;
}
.bs-timeline ::v-deep .bubbleAxis {
position: absolute;
top: -15px;
width: 18px;
height: 18px;
text-align: center;
background: rgb(247, 84, 9);
border-radius: 50% 50% 2% 50%;
transform: rotate(45deg) translateX(-65%);
left: 50%;
cursor: pointer;
}
.bs-timeline ::v-deep .bubbleAxis .bubbleAxis-inner {
color: #ffffff;
transform: rotate(-45deg);
}
.bs-timeline ::v-deep .bubbleText {
padding: 0 2px;
position: absolute;
bottom: -20px;
transform: translateX(-35%);
left: 50%;
cursor: pointer;
text-align: center;
border-radius: 5px;
white-space: nowrap;
pointer-events: none;
}
.bs-timeline .prev:hover,
.bs-timeline .next:hover {
transform: scale(1.1) translateY(-50%) scale(1.1);
box-shadow: #333 0px 0px 3px;
}
.bs-timeline ::v-deep .axisHeight:hover {
height: 68px !important;
transform: translateX(-65%);
z-index: 9;
}
.bs-timeline .prev,
.bs-timeline .next,
.bs-timeline .time-main,
.bs-timeline .line {
margin-top: -10px;
}
</style>