本文详细介绍如何使用JavaScript开发一个支持二维周视图的日历组件。文章从结构准备入手,解析分类轴与日期轴的网格绘制逻辑,重点阐述内容层与网格层的分离设计。针对内容插入、时间冲突检测及跨周计算等难点提供解决方案,并深入讲解基于mousedown/mouseup的点击与范围选择交互实现,附带完整源码参考。
前言与概述
前言
本文介绍了基于JavaScript开发二维周视图日历的技术方案。在先前实现月视图日历的基础上,本文将重点讲解如何构建一个支持二维展示的周视图日历组件。
接下来将对实现过程中的关键逻辑进行详细分析。
结构准备与网格分离设计
结构准备
与单层日历不同,周视图引入了分类轴,用于展示不同的业务类目,主要应用于一周内的日程或会议安排。
二维视图的核心优势在于性能优化:在切换日期时,无需重新渲染整个日历,因为分类轴保持不变,仅需更新日期显示部分。
由于是二维结构,插入的内容必然同时归属于特定的分类和时间段。内容在时间轴上可能跨越多个日期,因此不能像单层日历那样直接将内容放入格子,而需要单独处理其定位与渲染。
此外,只要分类保持不变,由日期和分类构成的背景网格无需重绘,这进一步提升了渲染效率。
鉴于上述特性,内容层与网格层必须分离。为了直观展示这一结构,现提供如下3D效果示意图:

即内容层独立悬浮于由时间和分类构成的网格之上。
基于以上分析,构建如下基本HTML结构:
<div class="ep-weekcalendar border">
<!-- 头部 -->
<div class="ep-weekcalendar-header">
<div class="ep-weekcalendar-header-left"></div>
<div class="ep-weekcalendar-header-center">
<span class="ep-weekcalendar-header-btn ep-weekcalendar-header-btn-prev"></span>
<span class="ep-weekcalendar-title">2017年12月04日 - 10日</span>
<span class="ep-weekcalendar-header-btn ep-weekcalendar-header-btn-next"></span>
</div>
<div class="ep-weekcalendar-header-right"></div>
</div>
<!-- 主体 -->
<div class="ep-weekcalendar-body">
<!-- 分类区域 -->
<div class="ep-weekcalendar-category-area">
<div class="ep-weekcalendar-category-header">
<span class="ep-weekcalendar-category-title">车辆</span>
</div>
<ul class="ep-weekcalendar-category-list">
</ul>
</div>
<!-- 内容区域 -->
<div class="ep-weekcalendar-time-area">
<!-- 每周日期渲染区域。切换日期时重新绘制内容 -->
<div class="ep-weekcalendar-weeks"></div>
<div class="ep-weekcalendar-main">
<!-- 分类和内容构建的网格区域,仅在分类改变时进行调整 -->
<div class="ep-weekcalendar-grid"> </div>
<!-- 可插入任意内容的区域,日期切换时清空,根据使用需求插入内容 -->
<div class="ep-weekcalendar-content"></div>
</div>
</div>
</div>
<!-- 底部 -->
<div class="ep-weekcalendar-body"></div>
</div>
上述结构的HTML实现代码较为简单,此处不再赘述。
分类与日期轴绘制实现
绘制实现
完成基础结构搭建后,接下来进行日历的具体绘制工作。
分类绘制
首先处理分类轴。在周视图中,一周的天数固定,确定分类后才能绘制主体网格。
对于分类数据,暂时考虑采用如下必要的数据格式:
{
id: 'cate-1', // 分类ID
name: '法拉利', // 分类名称
content: '苏E00000' // 分类的具体描述
}
具体实现代码如下:
{
// 设置分类数据
setCategory: function (data) {
if (!(data instanceof Array)) {
this.throwError('分类数据必须是一个数组');
return;
}
this._categoryData = data;
// 绘制分类
this._renderCatagories();
// 绘制其他需要改变的部分
this._renderChanged();
},
// 左侧分类渲染
_renderCatagories: function () {
this._categoryListEl.innerHTML = '';
var i = 0,
data = this._categoryData,
node = document.createElement('li'),
cataEl;
node.className = 'ep-weekcalendar-category';
// 用行作为下标记录当前分类id集合
this._categoryIndexs = [];
// id为键记录索引
this._categoryReocrds = {};
while (i < data.length) {
this._categoryIndexs.push(data[i].id);
this._categoryReocrds[data[i].id] = i;
cataEl = node.cloneNode(true);
this._rendercategory(data[i], cataEl);
i++;
}
},
_rendercategory: function (cate, cateEl) {
cateEl.setAttribute('data-cateid', cate.id);
var titleEl = document.createElement('span'),
contentEl = document.createElement('span');
titleEl.className = 'title';
contentEl.className = 'content';
titleEl.innerHTML = cate.name;
contentEl.innerHTML = cate.content;
cateEl.appendChild(titleEl);
cateEl.appendChild(contentEl);
this.fire('categoryRender', {
categoryEl: cateEl,
titleEl: titleEl,
contentEl: contentEl
});
this._categoryListEl.appendChild(cateEl);
this.fire('agterCategoryRender', {
categoryEl: cateEl,
titleEl: titleEl,
contentEl: contentEl
});
}
}
上述代码通过设置分类数据 `setCategory` 作为入口,调用绘制分类方法。其中还调用了 `_renderChanged` 方法,用于重新绘制日历的可变部分(如标题、日期及内容),该部分将在后续介绍。
日期绘制
在准备好分类轴后,需绘制日期轴。对于周视图,实现相对简单:根据一周的开始日期,依次渲染7天即可。注意在绘制过程中需向相应事件提供日期的必要信息,以便使用者在事件中处理个性化逻辑。
{
// 渲染日历的星期
_renderWeeks: function () {
this._weeksEl.innerHTML = '';
var i = 0,
currDate = this._startDate.clone(),
node = document.createElement('div'),
week;
node.className = 'ep-weekcalendar-week';
// 单元格列作为下标记录日期
this._dateRecords = [];
while (i++ < 7) {
// 更新记录日期
this._dateRecords.push(currDate.clone());
week = node.cloneNode(true);
this._renderWeek(currDate, week);
currDate.add(1, 'day');
}
// 切换日期 需要重绘内容区域
this._rednerContent();
},
_renderWeek: function (date, node) {
var dateText = date.format('YYYY-MM-DD'),
day = date.isoWeekday();
if (day > 5) {
node.className += ' weekend';
}
if (date.isSame(this.today, 'day')) {
node.className += ' today';
}
node.setAttribute('data-date', dateText);
node.setAttribute('date-isoweekday', day);
var ev = this.fire('dateRender', {
// 当前完整日期
date: dateText,
// iso星期
isoWeekday: day,
// 显示的文本
dateText: '周' + this._WEEKSNAME[day - 1] + ' ' + date.format('MM-DD'),
// classname
dateCls: node.className,
// 日历el
el: this.el,
// 当前el
dateEl: node
});
// 处理事件的修改
node.innerHTML = ev.dateText;
node.className = ev.dateCls;
this._weeksEl.appendChild(node);
this.fire('afterDateRender', {
// 当前完整日期
date: dateText,
// iso星期
isoWeekday: day,
// 显示的文本
dateText: node.innerHTML,
// classname
dateCls: node.className,
// 日历el
el: this.el,
// 当前el
dateEl: node
});
}
}
网格层与内容层处理逻辑
网格和内容
二维视图的两个轴已准备就绪,接下来进行网格层和内容层的绘制。
网格
此处以分类为Y方向(行),日期为X方向(列)进行网格绘制:
{
// 右侧网格
_renderGrid: function () {
this._gridEl.innerHTML = '';
var rowNode = document.createElement('div'),
itemNode = document.createElement('span'),
rowsNum = this._categoryData.length,
i = 0,
j = 0,
row, item;
rowNode.className = 'ep-weekcalendar-grid-row';
itemNode.className = 'ep-weekcalendar-grid-item';
while (i < rowsNum) {
row = rowNode.cloneNode();
row.setAttribute('data-i', i);
j = 0;
while (j < 7) {
item = itemNode.cloneNode();
// 周末标识
if (this.dayStartFromSunday) {
if (j === 0 || j === 6) {
item.className += ' weekend';
}
} else {
if (j > 4) {
item.className += ' weekend';
}
}
item.setAttribute('data-i', i);
item.setAttribute('data-j', j);
row.appendChild(item);
j++;
}
this._gridEl.appendChild(row);
i++;
}
rowNode = itemNode = row = item = null;
}
}
内容
理论上,二维视图需支持跨行(跨分类)和跨列(跨时间)两种情况,即内容区域应为一整块元素。但结合实际场景,跨时间的需求普遍存在(某事项在一段时间内连续使用),而跨分类的实际意义不大。若强行跨分类,管理复杂度会显著增加。即使需要同时管理多个分类下的事项,也可通过分别指定各分类的使用时间段来实现(此时时间段可能重合)。
因此,此处仅处理跨时间情况。将内容按行(即分类)进行绘制,可大幅简化插入内容部件时的计算逻辑。
{
// 右侧内容
_rednerContent: function () {
this._contentEl.innerHTML = '';
var i = 0,
node = document.createElement('div'),
row;
node.className = 'ep-weekcalendar-content-row';
while (i < this._categoryData.length) {
row = node.cloneNode();
row.setAttribute('data-i', i);
this._contentEl.appendChild(row);
++i;
}
row = node = null;
},
// 日期切换时清空内容
_clearContent: function () {
var rows = this._contentEl.childNodes,
i = 0;
while (i < rows.length) {
rows[i].innerHTML && (rows[i].innerHTML = '');
++i;
}
// 部件数据清空
this._widgetData = {};
}
}
若必须实现跨行跨列,需将内容绘制为整块元素,但在点击事件和插入部件时,需同时计算对应的分类和时间位置,逻辑较为复杂。
内容插入与时间冲突检测
难点实现
内容部件插入
开发二维周视图日历的主要目的是支持插入任意内容。前文已准备好插入内容的DOM元素,现在的任务是将数据渲染为DOM并放置在合适的位置。
考虑必要的内容部件数据结构如下:
{
id: '数据标识',
categoryId: '所属分类标识',
title: '名称',
content: '内容',
start: '开始日期时间'
end: '结束日期时间'
bgColor: '展示的背景色'
}
由于内容区域已按分类绘制,获取数据后分类已确定。重点在于根据指定的开始和结束时间,计算其在网格中的起始和结束位置。
具体计算逻辑如下:
- 考虑响应式布局,位置计算采用百分比方式。
- 一周的总时间是固定的,开始日期时间与本周起始时间的差额占总时间的百分比,即为开始位置的百分比。
- 结束日期时间与开始时间的差额占总时间的百分比,即为结束时间距离最左侧的百分比。
- 需注意处理开始和结束时间溢出本周边界的情况。
因此,关于位置计算的代码处理如下:
{
// 日期时间分隔符 默认为空 对应格式为 '2017-11-11 20:00'
// 对于'2017-11-11T20:00' 这样的格式务必指定正确的日期和时间之间的分隔符T
_dateTimeSplit:' ',
// 一周分钟数
_WEEKMINUTES: 7 * 24 * 60,
// 一周秒数
_WEEKSECONDS: 7 * 24 * 3600,
// 一天的分钟数秒数
_DAYMINUTES: 24 * 60,
_DAYSCONDS: 24 * 3600,
// 计算位置的精度 取值second 或 minute
posUnit: 'second',
// 计算指定日期的分钟或秒数
_getNumByUnits: function (dateStr) {
var temp = dateStr.split(this._dateTimeSplit),
date = temp[0];
// 处理左侧溢出
if (this._startDate.isAfter(date, 'day')) {
// 指定日期在开始日期之前
return 0;
}
// 右侧溢出直接算作第7天即可
var times = (temp[1] || '').split(':'),
days = (function (startDate) {
var currDate = startDate.clone(),
i = 0,
d = moment(date, 'YYYY-MM-DD');
while (i < 7) {
if (currDate.isSame(d, 'day')) {
return i;
} else {
currDate.add(1, 'day');
++i;
}
}
console && console.error && console.error('计算天数时出错!');
return i;
}(this._startDate)),
hours = parseInt(times[0], 10) || 0,
minutes = parseInt(times[1], 10) || 0,
seconds = parseInt(times[2], 10) || 0,
// 对应分钟数
result = days * this._DAYMINUTES + hours * 60 + minutes;
return this.posUnit == 'minute' ? result : (result * 60 + seconds);
},
// 计算日期时间的百分比位置
_getPos: function (dateStr) {
var p = this._getNumByUnits(dateStr) / (this.posUnit == 'minute' ? this._WEEKMINUTES : this._WEEKSECONDS);
return p > 1 ? 1 : p;
}
}
至此,已获取数据对应的开始和结束位置。基本功能已完成,但还需处理同一分类下的时间冲突问题。
考虑采用如下方式解决:
- 每添加一个事项,即记录其数据。
- 若新增事项与当前分类下已有事项的时间重叠,则判定为冲突。
具体实现如下:
{
/**
* 检查是否发生重叠
*
* @param {Object} data 当前要加入的数据
* @returns false 或 和当前部件重叠的元素数组
*/
_checkOccupied: function (data) {
if (!this._widgetData[data.categoryId]) {
return false;
}
var i = 0,
cate = this._widgetData[data.categoryId],
len = cate.length,
result = false,
occupied = [];
for (; i < len; ++i) {
// 判断时间是否存在重叠
if (data.start < cate[i].end && data.end > cate[i].start) {
occupied.push(cate[i]);
result = true;
}
}
return result ? occupied : false;
}
}
完成以上两步,即可向内容区域插入事项。
{
// 缓存widget数据
_cacheWidgetData: function (data) {
if (!this._widgetData[data.categoryId]) {
this._widgetData[data.categoryId] = [];
}
// 记录当前的
this._widgetData[data.categoryId].push(data);
},
// 新增一个小部件
addWidget: function (data) {
var row = this._contentEl.childNodes[this._categoryReocrds[data.categoryId]];
if (!row) {
this.throwError('对应分类不存在,添加失败');
return false;
}
// 先查找是否含有
var $aim = jQuery('.ep-weekcalendar-content-widget[data-id="' + data.id + '"]', row);
if ($aim.length) {
// 已经存在则不添加
return $aim[0];
}
// 创建部件
var widget = document.createElement('div'),
title = document.createElement('span'),
content = document.createElement('p'),
startPos = this._getPos(data.start),
endPos = this._getPos(data.end),
_data = {
categoryId: data.categoryId,
id: data.id,
start: startPos,
end: endPos,
el: widget,
data: data
};
widget.className = 'ep-weekcalendar-content-widget';
title.className = 'ep-weekcalendar-content-widget-title';
content.className = 'ep-weekcalendar-content-widget-content';
widget.appendChild(title);
widget.appendChild(content);
// 通过绝对定位,指定其left和right来拉开宽度的方式来处理响应式
// 可以通过样式设置一个最小宽度,来避免时间段过小时其中文本无法显示的问题
widget.style.left = startPos * 100 + '%';
widget.style.right = (1 - endPos) * 100 + '%';
data.bgColor && (widget.style.backgroundColor = data.bgColor);
data.id && widget.setAttribute('data-id', data.id);
widget.setAttribute('data-start', data.start);
widget.setAttribute('data-end', data.end);
title.innerHTML = data.title;
data.content && (content.innerHTML = data.content);
widget.title = data.title;
// 检查是否发生重叠
var isoccupied = this._checkOccupied(_data);
if (isoccupied) {
// 触发重叠事件
var occupiedEv = this.fire('widgetoccupied', {
occupiedWidgets: (function () {
var arr = [];
for (var i = 0, l = isoccupied.length; i < l; ++i) {
arr.push(isoccupied[i].el);
}
return arr;
})(),
currWidget: widget,
widgetData: data
});
// 取消后续执行
if (occupiedEv.cancel) {
return false;
}
}
// 缓存数据
this._cacheWidgetData(_data);
var addEv = this.fire('widgetAdd', {
widgetId: data.id,
categoryId: data.categoryId,
start: data.start,
end: data.end,
startPos: startPos,
endPos: endPos,
widgetEl: widget
});
if (addEv.cancel) {
return false;
}
row.appendChild(widget);
this.fire('afterWidgetAdd', {
widgetId: data.id,
categoryId: data.categoryId,
start: data.start,
end: data.end,
startPos: startPos,
endPos: endPos,
widgetEl: widget
});
return widget;
},
}
点击交互与范围选择机制
点击事件和范围选择
该控件不仅用于展示结果,还需支持点击添加。由于内容层覆盖在网格之上,用户无法直接点击到底层的网格元素,必须根据点击位置计算对应的日期和分类。
同时,由于展示的是时间范围部件,仅返回某天和分类不足以满足需求,还需支持鼠标按下、拖动再松开,以直接选择一段时间范围。
基于上述需求,不能直接使用 `click` 事件,而应考虑使用 `mousedown` 和 `mouseup` 来处理点击逻辑,并在 `mousemove` 中实时响应用户操作。
{
_initEvent: function () {
var me = this;
// 点击的行索引
var row,
// 开始列索引
columnStart,
// 结束列索引
columnEnd,
// 是否在按下、移动、松开的click中
isDurringClick = false,
// 是否移动过 用于处理按下没有移动直接松开的过程
isMoveing = false,
$columns,
// 网格左侧宽度
gridLeft,
// 每列的宽度
columnWidth
jQuery(this.el)
// 按下鼠标 记录分类和开始列
.on('mousedown.weekcalendar', '.ep-weekcalendar-content-row', function (e) {
isDurringClick = true;
gridLeft = jQuery(me._gridEl).offset().left;
columnWidth = jQuery(me._gridEl).width() / 7;
jQuery(me._gridEl).find('.ep-weekcalendar-grid-item').removeClass(me._selectedCls);
row = this.getAttribute('data-i');
$columns = jQuery(me._gridEl).find('.ep-weekcalendar-grid-row').eq(row).children();
columnStart = (e.pageX - gridLeft) / columnWidth >> 0;
});
// 移动和松开 松开鼠标 记录结束列 触发点击事件
// 不能直接绑定在日期容器上 否则鼠标移出日历后,松开鼠标,实际点击已经结束,但是日历上处理不到。
jQuery('body')
// 点击移动过程中 实时响应选中状态
.on('mousemove.weekcalendar', function (e) {
if (!isDurringClick) {
return;
}
isMoveing = true;
// 当前列索引
var currColumn;
// mousemoveTimer = setTimeout(function () {
currColumn = (e.pageX - gridLeft) / columnWidth >> 0;
// 修正溢出
currColumn = currColumn > 6 ? 6 : currColumn;
currColumn = currColumn < 0 ? 0 : currColumn;
$columns.removeClass(me._selectedCls);
// 起止依次选中
var start = Math.min(columnStart, currColumn),
end = Math.max(columnStart, currColumn);
do {
$columns.eq(start).addClass(me._selectedCls);
} while (++start <= end);
})
// 鼠标松开
.on('mouseup.weekcalendar', function (e) {
if (!isDurringClick) {
return;
}
var startIndex = -1,
endIndex = -1;
columnEnd = (e.pageX - gridLeft) / columnWidth >> 0;
columnEnd = columnEnd > 6 ? 6 : columnEnd;
// 没有移动过时
if (!isMoveing) {
startIndex = endIndex = columnEnd;
// 直接down up 没有move的过程则只会有一个选中的,直接以结束的作为处理即可
$columns.eq(columnEnd).addClass(me._selectedCls)
.siblings().removeClass(me._selectedCls);
} else {
startIndex = Math.min(columnStart, columnEnd);
endIndex = Math.max(columnStart, columnEnd);
}
// 触发点击事件
me.fire('cellClick', {
// 分类id
categoryId: me._categoryIndexs[row],
// 时间1
startDate: me._dateRecords[startIndex].format('YYYY-MM-DD'),
// 日期2
endDate: me._dateRecords[endIndex].format('YYYY-MM-DD'),
// 行索引
rowIndex: row,
// 列范围
columnIndexs: (function (i, j) {
var arr = [];
while (i <= j) {
arr.push(i++);
}
return arr;
}(startIndex, endIndex))
});
row = columnStart = columnEnd = isMoveing = isDurringClick = false;
});
}
}
此过程需注意:`mousedown` 必须绑定在日历容器上,而 `mouseup` 和 `mousemove` 不能绑定在日历上,具体原因已在上方代码注释中说明。
另外需注意,由于范围选择模拟了 `mousedown` 和 `mouseup`,日历内容区域中插入的数据部件的点击事件也需模拟相同机制。因为 `mouseup` 触发早于 `click`,若使用 `click`,会导致先触发日历上的日期点击或范围点击,从而引发逻辑错误。
使用示例与源码参考
使用
此日历实现基于一个控件基类扩展而来,其必要功能仅包含一套事件机制,可参考实现一套自定义事件机制。
实测一下效果吧:
<div id="week-calendar" style="width:100%;height:80vh"></div>
<script>
var calendar = epctrl.init('WeekCalendar', {
el: '#week-calendar',
categoryTitle: '车辆',
category: [{
id: 'cate-1',
name: '法拉利',
content: '苏E00000'
}, {
id: 'cate-2',
name: 'Lamborghini',
content: '苏E00001'
}, {
id: 'cate-3',
name: '捷豹',
content: '苏E00002'
}, {
id: 'cate-4',
name: '宾利',
content: '苏E00003'
}, {
id: 'cate-5',
name: 'SSC',
content: '苏E00004'
}],
events: {
// 日期变化时触发
dateChanged: function (e) {
var data = {
start: e.startDate,
end: e.endDate,
};
// 获取数据并逐个添加到日历上
getData(data).done(function (data) {
$.each(data, function (i, item) {
calendar.addWidget(item);
});
});
},
// 部件重叠时触发
widgetOccupied: function (e) {
// 冲突时禁止继续添加
console.error(e.widgetData.categoryId + '分类下id为' + e.widgetData.id + '的部件和现有部件有重叠,取消添加');
e.cancel = true;
}
}
});
calendar.on('dateClick', function (e) {
alert(JSON.stringify({
'开始时间': e.startDate,
'结束时间': e.endDate,
'分类id': e.categoryId,
'行索引': e.rowIndex,
'列索引范围': e.columnIndexs
}, 0, 4));
});
</script>
源码下载:
github
总结
以上就是这篇文章的全部内容,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对本站的支持。