UNPKG

vis-timeline

Version:

Create a fully customizable, interactive timeline with items and ranges.

629 lines (534 loc) 15.3 kB
import Item from './Item'; import util from 'vis-util'; /** * ClusterItem */ class ClusterItem extends Item { /** * @constructor Item * @param {Object} data Object containing (optional) parameters type, * start, end, content, group, className. * @param {{toScreen: function, toTime: function}} conversion * Conversion functions from time to screen and vice versa * @param {Object} options Configuration options * // TODO: describe available options */ constructor(data, conversion, options) { super(); this.props = { content: { width: 0, height: 0 }, }; this.options = Object.assign({}, {fitOnDoubleClick: true}, options, {editable: false}); if (!data || data.uiItems == undefined) { throw new Error('Property "uiItems" missing in item ' + data.id); } Item.call(this, data, conversion, this.options); this.id = util.randomUUID(); this.group = data.group; this._setupRange(); this.emitter = this.data.eventEmitter; this.range = this.data.range; this.attached = false; this.isCluster = true; this.data.isCluster = true; } /** * check if there are items * @return {boolean} */ hasItems() { return this.data.uiItems && this.data.uiItems.length && this.attached; } /** * set UI items * @param {array} items */ setUiItems(items) { this.detach(); this.data.uiItems = items; this._setupRange(); this.attach(); } /** * check is visible * @param {object} range * @return {boolean} */ isVisible(range) { const rangeWidth = this.data.end ? this.data.end - this.data.start : 0; const widthInMs = this.width * range.getMillisecondsPerPixel(); const end = Math.max(rangeWidth, this.data.start.getTime() + widthInMs); return (this.data.start < range.end) && (end > range.start) && this.hasItems(); } /** * get cluster data * @return {object} */ getData() { return { isCluster: true, id: this.id, items: this.data.items || [], data: this.data } } /** * redraw cluster item * @param {boolean} returnQueue * @return {boolean} */ redraw (returnQueue) { var sizes var queue = [ // create item DOM this._createDomElement.bind(this), // append DOM to parent DOM this._appendDomElement.bind(this), // update dirty DOM this._updateDirtyDomComponents.bind(this), (function () { if (this.dirty) { sizes = this._getDomComponentsSizes(); } }).bind(this), (function () { if (this.dirty) { this._updateDomComponentsSizes.bind(this)(sizes); } }).bind(this), // repaint DOM additionals this._repaintDomAdditionals.bind(this) ]; if (returnQueue) { return queue; } else { var result; queue.forEach(function (fn) { result = fn(); }); return result; } } /** * show cluster item */ show() { if (!this.displayed) { this.redraw(); } } /** * Hide the item from the DOM (when visible) */ hide() { if (this.displayed) { var dom = this.dom; if (dom.box.parentNode) { dom.box.parentNode.removeChild(dom.box); } if (this.options.showStipes) { if (dom.line.parentNode) { dom.line.parentNode.removeChild(dom.line); } if (dom.dot.parentNode) { dom.dot.parentNode.removeChild(dom.dot); } } this.displayed = false; } } /** * reposition item x axis */ repositionX() { let start = this.conversion.toScreen(this.data.start); let end = this.data.end ? this.conversion.toScreen(this.data.end) : 0; if (end) { this.repositionXWithRanges(start, end); } else { let align = this.data.align === undefined ? this.options.align : this.data.align; this.repositionXWithoutRanges(start, align); } if (this.options.showStipes) { this.dom.line.style.display = this._isStipeVisible() ? 'block' : 'none'; this.dom.dot.style.display = this._isStipeVisible() ? 'block' : 'none'; if (this._isStipeVisible()) { this.repositionStype(start, end); } } } /** * reposition item stype * @param {date} start * @param {date} end */ repositionStype(start, end) { this.dom.line.style.display = 'block'; this.dom.dot.style.display = 'block'; if (end) { this.dom.line.style.left = this.dom.line.offsetWidth + start + (end - start) / 2 + 'px'; this.dom.dot.style.left = this.dom.line.offsetWidth + start + (end - start) / 2 - this.dom.dot.offsetWidth / 2 + 'px' } else { this.dom.line.style.left = `${start}px`; this.dom.dot.style.left = `${start - this.dom.dot.offsetWidth / 2}px`; } } /** * reposition x without ranges * @param {date} start * @param {string} align */ repositionXWithoutRanges(start, align) { // calculate left position of the box if (align == 'right') { if (this.options.rtl) { this.right = start - this.width; // reposition box, line, and dot this.dom.box.style.right = this.right + 'px'; } else { this.left = start - this.width; // reposition box, line, and dot this.dom.box.style.left = this.left + 'px'; } } else if (align == 'left') { if (this.options.rtl) { this.right = start; // reposition box, line, and dot this.dom.box.style.right = this.right + 'px'; } else { this.left = start; // reposition box, line, and dot this.dom.box.style.left = this.left + 'px'; } } else { // default or 'center' if (this.options.rtl) { this.right = start - this.width / 2; // reposition box, line, and dot this.dom.box.style.right = this.right + 'px'; } else { this.left = start - this.width / 2; // reposition box, line, and dot this.dom.box.style.left = this.left + 'px'; } } } /** * reposition x with ranges * @param {date} start * @param {date} end */ repositionXWithRanges(start, end) { let boxWidth = Math.round(Math.max(end - start + 0.5, 1)); if (this.options.rtl) { this.right = start; } else { this.left = start; } this.width = Math.max(boxWidth, this.minWidth || 0); if (this.options.rtl) { this.dom.box.style.right = this.right + 'px'; } else { this.dom.box.style.left = this.left + 'px'; } this.dom.box.style.width = boxWidth + 'px'; } /** * reposition item y axis */ repositionY() { var orientation = this.options.orientation.item; var box = this.dom.box; if (orientation == 'top') { box.style.top = (this.top || 0) + 'px'; } else { // orientation 'bottom' box.style.top = (this.parent.height - this.top - this.height || 0) + 'px'; } if (this.options.showStipes) { if (orientation == 'top') { this.dom.line.style.top = '0'; this.dom.line.style.height = (this.parent.top + this.top + 1) + 'px'; this.dom.line.style.bottom = ''; } else { // orientation 'bottom' var itemSetHeight = this.parent.itemSet.props.height; var lineHeight = itemSetHeight - this.parent.top - this.parent.height + this.top; this.dom.line.style.top = (itemSetHeight - lineHeight) + 'px'; this.dom.line.style.bottom = '0'; } this.dom.dot.style.top = (-this.dom.dot.offsetHeight / 2) + 'px'; } } /** * get width left * @return {number} */ getWidthLeft() { return this.width / 2; } /** * get width right * @return {number} */ getWidthRight() { return this.width / 2; } /** * move cluster item */ move() { this.repositionX(); this.repositionY(); } /** * attach */ attach() { for (let item of this.data.uiItems) { item.cluster = this; } this.data.items = this.data.uiItems.map(item => item.data); this.attached = true; this.dirty = true; } /** * detach * @param {boolean} detachFromParent * @return {void} */ detach(detachFromParent = false) { if (!this.hasItems()) { return; } for (let item of this.data.uiItems) { delete item.cluster; } this.attached = false; if (detachFromParent && this.group) { this.group.remove(this); this.group = null; } this.data.items = []; this.dirty = true; } /** * handle on double click */ _onDoubleClick() { this._fit(); } /** * set range */ _setupRange() { const stats = this.data.uiItems.map(item => ({ start: item.data.start.valueOf(), end: item.data.end ? item.data.end.valueOf() : item.data.start.valueOf(), })); this.data.min = Math.min(...stats.map(s => Math.min(s.start, s.end || s.start))); this.data.max = Math.max(...stats.map(s => Math.max(s.start, s.end || s.start))); const centers = this.data.uiItems.map(item => item.center); const avg = centers.reduce((sum, value) => sum + value, 0) / this.data.uiItems.length; if (this.data.uiItems.some(item => item.data.end)) { // contains ranges this.data.start = new Date(this.data.min); this.data.end = new Date(this.data.max); } else { this.data.start = new Date(avg); this.data.end = null; } } /** * get UI items * @return {array} */ _getUiItems() { if (this.data.uiItems && this.data.uiItems.length) { return this.data.uiItems.filter(item => item.cluster === this); } return []; } /** * create DOM element */ _createDomElement() { if (!this.dom) { // create DOM this.dom = {}; // create main box this.dom.box = document.createElement('DIV'); // contents box (inside the background box). used for making margins this.dom.content = document.createElement('DIV'); this.dom.content.className = 'vis-item-content'; this.dom.box.appendChild(this.dom.content); if (this.options.showStipes) { // line to axis this.dom.line = document.createElement('DIV'); this.dom.line.className = 'vis-cluster-line'; this.dom.line.style.display = 'none'; // dot on axis this.dom.dot = document.createElement('DIV'); this.dom.dot.className = 'vis-cluster-dot'; this.dom.dot.style.display = 'none'; } if (this.options.fitOnDoubleClick) { this.dom.box.ondblclick = ClusterItem.prototype._onDoubleClick.bind(this); } // attach this item as attribute this.dom.box['vis-item'] = this; this.dirty = true; } } /** * append element to DOM */ _appendDomElement() { if (!this.parent) { throw new Error('Cannot redraw item: no parent attached'); } if (!this.dom.box.parentNode) { const foreground = this.parent.dom.foreground; if (!foreground) { throw new Error('Cannot redraw item: parent has no foreground container element'); } foreground.appendChild(this.dom.box); } const background = this.parent.dom.background; if (this.options.showStipes) { if (!this.dom.line.parentNode) { if (!background) throw new Error('Cannot redraw item: parent has no background container element'); background.appendChild(this.dom.line); } if (!this.dom.dot.parentNode) { var axis = this.parent.dom.axis; if (!background) throw new Error('Cannot redraw item: parent has no axis container element'); axis.appendChild(this.dom.dot); } } this.displayed = true; } /** * update dirty DOM components */ _updateDirtyDomComponents() { // An item is marked dirty when: // - the item is not yet rendered // - the item's data is changed // - the item is selected/deselected if (this.dirty) { this._updateContents(this.dom.content); this._updateDataAttributes(this.dom.box); this._updateStyle(this.dom.box); // update class const className = this.baseClassName + ' ' + (this.data.className ? ' ' + this.data.className : '') + (this.selected ? ' vis-selected' : '') + ' vis-readonly'; this.dom.box.className = 'vis-item ' + className; if (this.options.showStipes) { this.dom.line.className = 'vis-item vis-cluster-line ' + (this.selected ? ' vis-selected' : ''); this.dom.dot.className = 'vis-item vis-cluster-dot ' + (this.selected ? ' vis-selected' : ''); } if (this.data.end) { // turn off max-width to be able to calculate the real width // this causes an extra browser repaint/reflow, but so be it this.dom.content.style.maxWidth = 'none'; } } } /** * get DOM components sizes * @return {object} */ _getDomComponentsSizes() { return { previous: { right: this.dom.box.style.right, left: this.dom.box.style.left }, box: { width: this.dom.box.offsetWidth, height: this.dom.box.offsetHeight }, } } /** * update DOM components sizes * @param {object} sizes */ _updateDomComponentsSizes(sizes) { if (this.options.rtl) { this.dom.box.style.right = "0px"; } else { this.dom.box.style.left = "0px"; } // recalculate size if (!this.data.end) { this.width = sizes.box.width; } else { this.minWidth = sizes.box.width; } this.height = sizes.box.height; // restore previous position if (this.options.rtl) { this.dom.box.style.right = sizes.previous.right; } else { this.dom.box.style.left = sizes.previous.left; } this.dirty = false; } /** * repaint DOM additional components */ _repaintDomAdditionals() { this._repaintOnItemUpdateTimeTooltip(this.dom.box); } /** * check is stripe visible * @return {number} * @private */ _isStipeVisible() { return this.minWidth >= this.width || !this.data.end; } /** * get fit range * @return {object} * @private */ _getFitRange() { const offset = 0.05*(this.data.max - this.data.min) / 2; return { fitStart: this.data.min - offset, fitEnd: this.data.max + offset }; } /** * fit * @private */ _fit() { if (this.emitter) { const {fitStart, fitEnd} = this._getFitRange(); const fitArgs = { start: new Date(fitStart), end: new Date(fitEnd), animation: true }; this.emitter.emit('fit', fitArgs); } } /** * get item data * @return {object} * @private */ _getItemData() { return this.data; } } ClusterItem.prototype.baseClassName = 'vis-item vis-range vis-cluster'; export default ClusterItem;