帶你開發一個二維周視圖日曆

即以前實現了一個月視圖日曆,咱們今天來實現一個二維周視圖的日曆。html

如下進行分析其中的關鍵部分。node

結構準備

不一樣之處在於其在日曆的基礎上還有一個分類軸,用於展現不一樣的類目,主要用於一週內的日程安排、會議安排等。git

二維則和以前單獨的有所不一樣,二維日曆再切換日期時不用所有從新渲染,分類是不用變的,僅僅改變顯示的日期便可。github

並且因爲是二維的,插入的內容一定是同時屬於一個分類和一個時間段的,內容確定是能夠跨越時間(即日期軸)的,所以不能直接將插入的內容像開始的日曆同樣直接放置在日曆的格子中。而要進行單獨的處理。數組

另外,只要分類不變,日期和分類構成的網格是不用重繪的。緩存

考慮到以上狀況,插入內容的和網格是須要分開來的,我將現成的日曆弄成一下3D效果示意:數據結構

即插入內容的層是單獨放置在時間和分類構成的網格上方的。app

基於以上分析,先構建以下基本結構:dom

<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>
複製代碼

結構如上,實現代碼就不用展現了。post

繪製實現

初始好了必要的結構,咱們接着進行日曆的繪製工做。

分類繪製

首先要處理的是分類,周視圖中,一週的天數是固定的,肯定好分類才能繪製出主體部分的網格。

對於分類,暫時考慮以下必要數據格式:

{
    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++;
        }
        // 分類重繪一定重繪網格和內容
        this._renderGrid();
        this._rednerContent();
    },
    _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' ? result : (result * 60 + seconds);
    },
    // 計算日期時間的百分比位置
    _getPos: function (dateStr) {
        var p = this._getNumByUnits(dateStr) / (this.posUnit == 'minute' ? this._WEEKMINUTES : this._WEEKSECONDS);

        return p > 1 ? 1 : p;
    }
}
複製代碼

上面就拿到了一個數據所對應的開始位置和結束位置。基本上是已經完成了,可是還須要再處理一個狀況:相同分類下的時間衝突問題。

考慮以以下方式進行:

  1. 沒添加一個就記錄下其數據
  2. 新增的若是和當前分類下已有的存在時間重疊,則認爲衝突。

實現以下:

{
    /**
     * 檢查是否發生重疊
     *
     * @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 來實現,考慮使用 mousedownmouseup 來處理點擊事件,同時須要在 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 必須綁定在日曆上,而 mouseupmousemove 則不能綁定在日曆上,具體緣由已經寫在上面代碼註釋中了。

另外須要注意,因爲範圍點擊選擇使用了 mousedownmouseup 來模擬,那麼日曆內容區域中插入的數據部件的點擊事件也要用 mousedownmouseup 來模擬,由於 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>
複製代碼

本文首發個人博客:blog.cdswyda.com/post/201712…

相關文章
相關標籤/搜索