网络编程 
首页 > 网络编程 > 浏览文章

利用Javascript开发一个二维周视图日历

(编辑:jimmy 日期: 2024/11/19 浏览:3 次 )

前言

本文给大家介绍了Javascript开发二维周视图日历的相关内容,即之前实现了一个月视图日历,我们今天来实现一个二维周视图的日历。

以下进行分析其中的关键部分。

结构准备

不同之处在于其在日历的基础上还有一个分类轴,用于展示不同的类目,主要用于一周内的日程安排、会议安排等。

二维则和之前单独的有所不同,二维日历再切换日期时不用全部重新渲染,分类是不用变的,仅仅改变显示的日期即可。

而且由于是二维的,插入的内容必定是同时属于一个分类和一个时间段的,内容肯定是可以跨越时间(即日期轴)的,因此不能直接将插入的内容像开始的日历一样直接放置在日历的格子中。而要进行单独的处理。

另外,只要分类不变,日期和分类构成的网格是不用重绘的。

考虑到以上情况,插入内容的和网格是需要分开来的,我将现成的日历弄成一下3D效果示意:

利用Javascript开发一个二维周视图日历

即插入内容的层是单独放置在时间和分类构成的网格上方的。

基于以上分析,先构建如下基本结构:

<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>

结构如上,实现代码就不用展示了。

绘制实现

初始好了必要的结构,我们接着进行日历的绘制工作。

分类绘制

首先要处理的是分类,周视图中,一周的天数是固定的,确定好分类才能绘制出主体部分的网格。

对于分类,暂时考虑如下必要数据格式:

{
 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;
 }
}

内容

理论上来说,二维要支持跨行、跨列两种情况,即内容区域应该为一整块元素。但是结合到实际情况,跨时间的需求普遍存在(一个东西在一段时间内被连续使用)。跨分类并没有多大的实际意义,本来就要分开以分类来管理,再跨分类,又变得复杂了。而且即使一定要实现一段时间内同时在使用多个东西,也是可以直接实现的(分类A在XX时间段内被使用,B在XX时间段内被使用,只是此时XX正好相同而已)。

因此此处仅处理跨时间情况,可将内容按行即分类进行绘制,这样在插入内容部件时,可以简化很多计算。

{
 // 右侧内容
 _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' "htmlcode">
{
 /**
 * 检查是否发生重叠
 *
 * @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 "htmlcode">
{
 // 缓存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 "color: #ff0000">使用

此日历实现基于一个控件基类扩展而来,其必要功能仅为一套事件机制,可参考实现一套自定义事件机制

实测一下效果吧:

<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

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对的支持。

上一篇:JavaScript重复元素处理方法分析【统计个数、计算、去重复等】
下一篇:浅谈es6 javascript的map数据结构
荣耀猎人回归!七大亮点看懂不只是轻薄本,更是游戏本的MagicBook Pro 16.
人们对于笔记本电脑有一个固有印象:要么轻薄但性能一般,要么性能强劲但笨重臃肿。然而,今年荣耀新推出的MagicBook Pro 16刷新了人们的认知——发布会上,荣耀宣布猎人游戏本正式回归,称其继承了荣耀 HUNTER 基因,并自信地为其打出“轻薄本,更是游戏本”的口号。
众所周知,寻求轻薄本的用户普遍更看重便携性、外观造型、静谧性和打字办公等用机体验,而寻求游戏本的用户则普遍更看重硬件配置、性能释放等硬核指标。把两个看似难以相干的产品融合到一起,我们不禁对它产生了强烈的好奇:作为代表荣耀猎人游戏本的跨界新物种,它究竟做了哪些平衡以兼顾不同人群的各类需求呢?