vendor/assets/vis/timeline/Timeline.js in vis-rails-1.0.2 vs vendor/assets/vis/timeline/Timeline.js in vis-rails-2.0.0

- old
+ new

@@ -4,273 +4,68 @@ * @param {vis.DataSet | Array | google.visualization.DataTable} [items] * @param {Object} [options] See Timeline.setOptions for the available options. * @constructor */ function Timeline (container, items, options) { - // validate arguments - if (!container) throw new Error('No container element provided'); - var me = this; - var now = moment().hours(0).minutes(0).seconds(0).milliseconds(0); this.defaultOptions = { - orientation: 'bottom', - direction: 'horizontal', // 'horizontal' or 'vertical' + start: null, + end: null, + autoResize: true, - stack: true, - editable: { - updateTime: false, - updateGroup: false, - add: false, - remove: false - }, - - selectable: true, - - min: null, - max: null, - zoomMin: 10, // milliseconds - zoomMax: 1000 * 60 * 60 * 24 * 365 * 10000, // milliseconds - // moveable: true, // TODO: option moveable - // zoomable: true, // TODO: option zoomable - - showMinorLabels: true, - showMajorLabels: true, - showCurrentTime: false, - showCustomTime: false, - - groupOrder: null, - + orientation: 'bottom', width: null, height: null, maxHeight: null, - minHeight: null, - - type: 'box', - align: 'center', - margin: { - axis: 20, - item: 10 - }, - padding: 5, - - onAdd: function (item, callback) { - callback(item); - }, - onUpdate: function (item, callback) { - callback(item); - }, - onMove: function (item, callback) { - callback(item); - }, - onRemove: function (item, callback) { - callback(item); - } + minHeight: null }; + this.options = util.deepExtend({}, this.defaultOptions); - this.options = {}; - util.deepExtend(this.options, this.defaultOptions); - util.deepExtend(this.options, { - snap: null, // will be specified after timeaxis is created + // Create the DOM, props, and emitter + this._create(container); - toScreen: me._toScreen.bind(me), - toTime: me._toTime.bind(me) - }); + // all components listed here will be repainted automatically + this.components = []; - // root panel - var rootOptions = util.extend(Object.create(this.options), { - height: function () { - if (me.options.height) { - // fixed height - return me.options.height; - } - else { - // auto height - // TODO: implement a css based solution to automatically have the right hight - return (me.timeAxis.height + me.contentPanel.height) + 'px'; - } - } - }); - this.rootPanel = new RootPanel(container, rootOptions); - - // single select (or unselect) when tapping an item - this.rootPanel.on('tap', this._onSelectItem.bind(this)); - - // multi select when holding mouse/touch, or on ctrl+click - this.rootPanel.on('hold', this._onMultiSelectItem.bind(this)); - - // add item on doubletap - this.rootPanel.on('doubletap', this._onAddItem.bind(this)); - - // side panel - var sideOptions = util.extend(Object.create(this.options), { - top: function () { - return (sideOptions.orientation == 'top') ? '0' : ''; + this.body = { + dom: this.dom, + domProps: this.props, + emitter: { + on: this.on.bind(this), + off: this.off.bind(this), + emit: this.emit.bind(this) }, - bottom: function () { - return (sideOptions.orientation == 'top') ? '' : '0'; - }, - left: '0', - right: null, - height: '100%', - width: function () { - if (me.itemSet) { - return me.itemSet.getLabelsWidth(); - } - else { - return 0; - } - }, - className: function () { - return 'side' + (me.groupsData ? '' : ' hidden'); + util: { + snap: null, // will be specified after TimeAxis is created + toScreen: me._toScreen.bind(me), + toTime: me._toTime.bind(me) } - }); - this.sidePanel = new Panel(sideOptions); - this.rootPanel.appendChild(this.sidePanel); + }; - // main panel (contains time axis and itemsets) - var mainOptions = util.extend(Object.create(this.options), { - left: function () { - // we align left to enable a smooth resizing of the window - return me.sidePanel.width; - }, - right: null, - height: '100%', - width: function () { - return me.rootPanel.width - me.sidePanel.width; - }, - className: 'main' - }); - this.mainPanel = new Panel(mainOptions); - this.rootPanel.appendChild(this.mainPanel); - // range - // TODO: move range inside rootPanel? - var rangeOptions = Object.create(this.options); - this.range = new Range(this.rootPanel, this.mainPanel, rangeOptions); - this.range.setRange( - now.clone().add('days', -3).valueOf(), - now.clone().add('days', 4).valueOf() - ); - this.range.on('rangechange', function (properties) { - me.rootPanel.repaint(); - me.emit('rangechange', properties); - }); - this.range.on('rangechanged', function (properties) { - me.rootPanel.repaint(); - me.emit('rangechanged', properties); - }); + this.range = new Range(this.body); + this.components.push(this.range); + this.body.range = this.range; - // panel with time axis - var timeAxisOptions = util.extend(Object.create(rootOptions), { - range: this.range, - left: null, - top: null, - width: null, - height: null - }); - this.timeAxis = new TimeAxis(timeAxisOptions); - this.timeAxis.setRange(this.range); - this.options.snap = this.timeAxis.snap.bind(this.timeAxis); - this.mainPanel.appendChild(this.timeAxis); + // time axis + this.timeAxis = new TimeAxis(this.body); + this.components.push(this.timeAxis); + this.body.util.snap = this.timeAxis.snap.bind(this.timeAxis); - // content panel (contains itemset(s)) - var contentOptions = util.extend(Object.create(this.options), { - top: function () { - return (me.options.orientation == 'top') ? (me.timeAxis.height + 'px') : ''; - }, - bottom: function () { - return (me.options.orientation == 'top') ? '' : (me.timeAxis.height + 'px'); - }, - left: null, - right: null, - height: null, - width: null, - className: 'content' - }); - this.contentPanel = new Panel(contentOptions); - this.mainPanel.appendChild(this.contentPanel); - - // content panel (contains the vertical lines of box items) - var backgroundOptions = util.extend(Object.create(this.options), { - top: function () { - return (me.options.orientation == 'top') ? (me.timeAxis.height + 'px') : ''; - }, - bottom: function () { - return (me.options.orientation == 'top') ? '' : (me.timeAxis.height + 'px'); - }, - left: null, - right: null, - height: function () { - return me.contentPanel.height; - }, - width: null, - className: 'background' - }); - this.backgroundPanel = new Panel(backgroundOptions); - this.mainPanel.insertBefore(this.backgroundPanel, this.contentPanel); - - // panel with axis holding the dots of item boxes - var axisPanelOptions = util.extend(Object.create(rootOptions), { - left: 0, - top: function () { - return (me.options.orientation == 'top') ? (me.timeAxis.height + 'px') : ''; - }, - bottom: function () { - return (me.options.orientation == 'top') ? '' : (me.timeAxis.height + 'px'); - }, - width: '100%', - height: 0, - className: 'axis' - }); - this.axisPanel = new Panel(axisPanelOptions); - this.mainPanel.appendChild(this.axisPanel); - - // content panel (contains itemset(s)) - var sideContentOptions = util.extend(Object.create(this.options), { - top: function () { - return (me.options.orientation == 'top') ? (me.timeAxis.height + 'px') : ''; - }, - bottom: function () { - return (me.options.orientation == 'top') ? '' : (me.timeAxis.height + 'px'); - }, - left: null, - right: null, - height: null, - width: null, - className: 'side-content' - }); - this.sideContentPanel = new Panel(sideContentOptions); - this.sidePanel.appendChild(this.sideContentPanel); - // current time bar - // Note: time bar will be attached in this.setOptions when selected - this.currentTime = new CurrentTime(this.range, rootOptions); + this.currentTime = new CurrentTime(this.body); + this.components.push(this.currentTime); // custom time bar // Note: time bar will be attached in this.setOptions when selected - this.customTime = new CustomTime(rootOptions); - this.customTime.on('timechange', function (time) { - me.emit('timechange', time); - }); - this.customTime.on('timechanged', function (time) { - me.emit('timechanged', time); - }); + this.customTime = new CustomTime(this.body); + this.components.push(this.customTime); - // itemset containing items and groups - var itemOptions = util.extend(Object.create(this.options), { - left: null, - right: null, - top: null, - bottom: null, - width: null, - height: null - }); - this.itemSet = new ItemSet(this.backgroundPanel, this.axisPanel, this.sideContentPanel, itemOptions); - this.itemSet.setRange(this.range); - this.itemSet.on('change', me.rootPanel.repaint.bind(me.rootPanel)); - this.contentPanel.appendChild(this.itemSet); + // item set + this.itemSet = new ItemSet(this.body); + this.components.push(this.itemSet); this.itemsData = null; // DataSet this.groupsData = null; // DataSet // apply options @@ -280,92 +75,221 @@ // create itemset if (items) { this.setItems(items); } + else { + this.redraw(); + } } // turn Timeline into an event emitter Emitter(Timeline.prototype); /** - * Set options - * @param {Object} options TODO: describe the available options + * Create the main DOM for the Timeline: a root panel containing left, right, + * top, bottom, content, and background panel. + * @param {Element} container The container element where the Timeline will + * be attached. + * @private */ -Timeline.prototype.setOptions = function (options) { - util.deepExtend(this.options, options); +Timeline.prototype._create = function (container) { + this.dom = {}; - if ('editable' in options) { - var isBoolean = typeof options.editable === 'boolean'; + this.dom.root = document.createElement('div'); + this.dom.background = document.createElement('div'); + this.dom.backgroundVertical = document.createElement('div'); + this.dom.backgroundHorizontal = document.createElement('div'); + this.dom.centerContainer = document.createElement('div'); + this.dom.leftContainer = document.createElement('div'); + this.dom.rightContainer = document.createElement('div'); + this.dom.center = document.createElement('div'); + this.dom.left = document.createElement('div'); + this.dom.right = document.createElement('div'); + this.dom.top = document.createElement('div'); + this.dom.bottom = document.createElement('div'); + this.dom.shadowTop = document.createElement('div'); + this.dom.shadowBottom = document.createElement('div'); + this.dom.shadowTopLeft = document.createElement('div'); + this.dom.shadowBottomLeft = document.createElement('div'); + this.dom.shadowTopRight = document.createElement('div'); + this.dom.shadowBottomRight = document.createElement('div'); - this.options.editable = { - updateTime: isBoolean ? options.editable : (options.editable.updateTime || false), - updateGroup: isBoolean ? options.editable : (options.editable.updateGroup || false), - add: isBoolean ? options.editable : (options.editable.add || false), - remove: isBoolean ? options.editable : (options.editable.remove || false) + this.dom.background.className = 'vispanel background'; + this.dom.backgroundVertical.className = 'vispanel background vertical'; + this.dom.backgroundHorizontal.className = 'vispanel background horizontal'; + this.dom.centerContainer.className = 'vispanel center'; + this.dom.leftContainer.className = 'vispanel left'; + this.dom.rightContainer.className = 'vispanel right'; + this.dom.top.className = 'vispanel top'; + this.dom.bottom.className = 'vispanel bottom'; + this.dom.left.className = 'content'; + this.dom.center.className = 'content'; + this.dom.right.className = 'content'; + this.dom.shadowTop.className = 'shadow top'; + this.dom.shadowBottom.className = 'shadow bottom'; + this.dom.shadowTopLeft.className = 'shadow top'; + this.dom.shadowBottomLeft.className = 'shadow bottom'; + this.dom.shadowTopRight.className = 'shadow top'; + this.dom.shadowBottomRight.className = 'shadow bottom'; + + this.dom.root.appendChild(this.dom.background); + this.dom.root.appendChild(this.dom.backgroundVertical); + this.dom.root.appendChild(this.dom.backgroundHorizontal); + this.dom.root.appendChild(this.dom.centerContainer); + this.dom.root.appendChild(this.dom.leftContainer); + this.dom.root.appendChild(this.dom.rightContainer); + this.dom.root.appendChild(this.dom.top); + this.dom.root.appendChild(this.dom.bottom); + + this.dom.centerContainer.appendChild(this.dom.center); + this.dom.leftContainer.appendChild(this.dom.left); + this.dom.rightContainer.appendChild(this.dom.right); + + this.dom.centerContainer.appendChild(this.dom.shadowTop); + this.dom.centerContainer.appendChild(this.dom.shadowBottom); + this.dom.leftContainer.appendChild(this.dom.shadowTopLeft); + this.dom.leftContainer.appendChild(this.dom.shadowBottomLeft); + this.dom.rightContainer.appendChild(this.dom.shadowTopRight); + this.dom.rightContainer.appendChild(this.dom.shadowBottomRight); + + this.on('rangechange', this.redraw.bind(this)); + this.on('change', this.redraw.bind(this)); + this.on('touch', this._onTouch.bind(this)); + this.on('pinch', this._onPinch.bind(this)); + this.on('dragstart', this._onDragStart.bind(this)); + this.on('drag', this._onDrag.bind(this)); + + // create event listeners for all interesting events, these events will be + // emitted via emitter + this.hammer = Hammer(this.dom.root, { + prevent_default: true + }); + this.listeners = {}; + + var me = this; + var events = [ + 'touch', 'pinch', + 'tap', 'doubletap', 'hold', + 'dragstart', 'drag', 'dragend', + 'mousewheel', 'DOMMouseScroll' // DOMMouseScroll is needed for Firefox + ]; + events.forEach(function (event) { + var listener = function () { + var args = [event].concat(Array.prototype.slice.call(arguments, 0)); + me.emit.apply(me, args); }; - } + me.hammer.on(event, listener); + me.listeners[event] = listener; + }); - // force update of range (apply new min/max etc.) - // both start and end are optional - this.range.setRange(options.start, options.end); + // size properties of each of the panels + this.props = { + root: {}, + background: {}, + centerContainer: {}, + leftContainer: {}, + rightContainer: {}, + center: {}, + left: {}, + right: {}, + top: {}, + bottom: {}, + border: {}, + scrollTop: 0, + scrollTopMin: 0 + }; + this.touch = {}; // store state information needed for touch events - if ('editable' in options || 'selectable' in options) { - if (this.options.selectable) { - // force update of selection - this.setSelection(this.getSelection()); - } - else { - // remove selection - this.setSelection([]); - } - } + // attach the root panel to the provided container + if (!container) throw new Error('No container provided'); + container.appendChild(this.dom.root); +}; - // force the itemSet to refresh: options like orientation and margins may be changed - this.itemSet.markDirty(); +/** + * Destroy the Timeline, clean up all DOM elements and event listeners. + */ +Timeline.prototype.destroy = function () { + // unbind datasets + this.clear(); - // validate the callback functions - var validateCallback = (function (fn) { - if (!(this.options[fn] instanceof Function) || this.options[fn].length != 2) { - throw new Error('option ' + fn + ' must be a function ' + fn + '(item, callback)'); - } - }).bind(this); - ['onAdd', 'onUpdate', 'onRemove', 'onMove'].forEach(validateCallback); + // remove all event listeners + this.off(); - // add/remove the current time bar - if (this.options.showCurrentTime) { - if (!this.mainPanel.hasChild(this.currentTime)) { - this.mainPanel.appendChild(this.currentTime); - this.currentTime.start(); - } + // stop checking for changed size + this._stopAutoResize(); + + // remove from DOM + if (this.dom.root.parentNode) { + this.dom.root.parentNode.removeChild(this.dom.root); } - else { - if (this.mainPanel.hasChild(this.currentTime)) { - this.currentTime.stop(); - this.mainPanel.removeChild(this.currentTime); + this.dom = null; + + // cleanup hammer touch events + for (var event in this.listeners) { + if (this.listeners.hasOwnProperty(event)) { + delete this.listeners[event]; } } + this.listeners = null; + this.hammer = null; - // add/remove the custom time bar - if (this.options.showCustomTime) { - if (!this.mainPanel.hasChild(this.customTime)) { - this.mainPanel.appendChild(this.customTime); - } + // give all components the opportunity to cleanup + this.components.forEach(function (component) { + component.destroy(); + }); + + this.body = null; +}; + +/** + * Set options. Options will be passed to all components loaded in the Timeline. + * @param {Object} [options] + * {String} orientation + * Vertical orientation for the Timeline, + * can be 'bottom' (default) or 'top'. + * {String | Number} width + * Width for the timeline, a number in pixels or + * a css string like '1000px' or '75%'. '100%' by default. + * {String | Number} height + * Fixed height for the Timeline, a number in pixels or + * a css string like '400px' or '75%'. If undefined, + * The Timeline will automatically size such that + * its contents fit. + * {String | Number} minHeight + * Minimum height for the Timeline, a number in pixels or + * a css string like '400px' or '75%'. + * {String | Number} maxHeight + * Maximum height for the Timeline, a number in pixels or + * a css string like '400px' or '75%'. + * {Number | Date | String} start + * Start date for the visible window + * {Number | Date | String} end + * End date for the visible window + */ +Timeline.prototype.setOptions = function (options) { + if (options) { + // copy the known options + var fields = ['width', 'height', 'minHeight', 'maxHeight', 'autoResize', 'start', 'end', 'orientation']; + util.selectiveExtend(fields, this.options, options); + + // enable/disable autoResize + this._initAutoResize(); } - else { - if (this.mainPanel.hasChild(this.customTime)) { - this.mainPanel.removeChild(this.customTime); - } - } + // propagate options to all components + this.components.forEach(function (component) { + component.setOptions(options); + }); + // TODO: remove deprecation error one day (deprecated since version 0.8.0) if (options && options.order) { throw new Error('Option order is deprecated. There is no replacement for this feature.'); } - // repaint everything - this.rootPanel.repaint(); + // redraw everything + this.redraw(); }; /** * Set a custom time bar * @param {Date} time @@ -406,36 +330,36 @@ newDataSet = items; } else { // turn an array into a dataset newDataSet = new DataSet(items, { - convert: { + type: { start: 'Date', end: 'Date' } }); } // set items this.itemsData = newDataSet; - this.itemSet.setItems(newDataSet); + this.itemSet && this.itemSet.setItems(newDataSet); - if (initialLoad && (this.options.start == undefined || this.options.end == undefined)) { + if (initialLoad && ('start' in this.options || 'end' in this.options)) { this.fit(); - var start = (this.options.start != undefined) ? util.convert(this.options.start, 'Date') : null; - var end = (this.options.end != undefined) ? util.convert(this.options.end, 'Date') : null; + var start = ('start' in this.options) ? util.convert(this.options.start, 'Date') : null; + var end = ('end' in this.options) ? util.convert(this.options.end, 'Date') : null; this.setWindow(start, end); } }; /** * Set groups * @param {vis.DataSet | Array | google.visualization.DataTable} groups */ -Timeline.prototype.setGroups = function setGroups(groups) { +Timeline.prototype.setGroups = function(groups) { // convert to type DataSet when needed var newDataSet; if (!groups) { newDataSet = null; } @@ -459,31 +383,35 @@ * timeline.clear({options: true}); // clear options only * * @param {Object} [what] Optionally specify what to clear. By default: * {items: true, groups: true, options: true} */ -Timeline.prototype.clear = function clear(what) { +Timeline.prototype.clear = function(what) { // clear items if (!what || what.items) { this.setItems(null); } // clear groups if (!what || what.groups) { this.setGroups(null); } - // clear options + // clear options of timeline and of each of the components if (!what || what.options) { - this.setOptions(this.defaultOptions); + this.components.forEach(function (component) { + component.setOptions(component.defaultOptions); + }); + + this.setOptions(this.defaultOptions); // this will also do a redraw } }; /** * Set Timeline window such that it fits all items */ -Timeline.prototype.fit = function fit() { +Timeline.prototype.fit = function() { // apply the data range as range var dataRange = this.getItemRange(); // add 5% space on both sides var start = dataRange.min; @@ -510,33 +438,35 @@ * Get the data range of the item set. * @returns {{min: Date, max: Date}} range A range with a start and end Date. * When no minimum is found, min==null * When no maximum is found, max==null */ -Timeline.prototype.getItemRange = function getItemRange() { +Timeline.prototype.getItemRange = function() { // calculate min from start filed var itemsData = this.itemsData, min = null, max = null; if (itemsData) { // calculate the minimum value of the field 'start' var minItem = itemsData.min('start'); - min = minItem ? minItem.start.valueOf() : null; + min = minItem ? util.convert(minItem.start, 'Date').valueOf() : null; + // Note: we convert first to Date and then to number because else + // a conversion from ISODate to Number will fail // calculate maximum value of fields 'start' and 'end' var maxStartItem = itemsData.max('start'); if (maxStartItem) { - max = maxStartItem.start.valueOf(); + max = util.convert(maxStartItem.start, 'Date').valueOf(); } var maxEndItem = itemsData.max('end'); if (maxEndItem) { if (max == null) { - max = maxEndItem.end.valueOf(); + max = util.convert(maxEndItem.end, 'Date').valueOf(); } else { - max = Math.max(max, maxEndItem.end.valueOf()); + max = Math.max(max, util.convert(maxEndItem.end, 'Date').valueOf()); } } } return { @@ -550,20 +480,20 @@ * Unknown id's are silently ignored. * @param {Array} [ids] An array with zero or more id's of the items to be * selected. If ids is an empty array, all items will be * unselected. */ -Timeline.prototype.setSelection = function setSelection (ids) { - this.itemSet.setSelection(ids); +Timeline.prototype.setSelection = function(ids) { + this.itemSet && this.itemSet.setSelection(ids); }; /** * Get the selected items by their id * @return {Array} ids The ids of the selected items */ -Timeline.prototype.getSelection = function getSelection() { - return this.itemSet.getSelection(); +Timeline.prototype.getSelection = function() { + return this.itemSet && this.itemSet.getSelection() || []; }; /** * Set the visible window. Both parameters are optional, you can change only * start or only end. Syntax: @@ -575,11 +505,11 @@ * object with properties start and end. * * @param {Date | Number | String | Object} [start] Start date of visible window * @param {Date | Number | String} [end] End date of visible window */ -Timeline.prototype.setWindow = function setWindow(start, end) { +Timeline.prototype.setWindow = function(start, end) { if (arguments.length == 1) { var range = arguments[0]; this.range.setRange(range.start, range.end); } else { @@ -589,166 +519,333 @@ /** * Get the visible window * @return {{start: Date, end: Date}} Visible range */ -Timeline.prototype.getWindow = function setWindow() { +Timeline.prototype.getWindow = function() { var range = this.range.getRange(); return { start: new Date(range.start), end: new Date(range.end) }; }; /** - * Force a repaint of the Timeline. Can be useful to manually repaint when + * Force a redraw of the Timeline. Can be useful to manually redraw when * option autoResize=false */ -Timeline.prototype.repaint = function repaint() { - this.rootPanel.repaint(); -}; +Timeline.prototype.redraw = function() { + var resized = false, + options = this.options, + props = this.props, + dom = this.dom; -/** - * Handle selecting/deselecting an item when tapping it - * @param {Event} event - * @private - */ -// TODO: move this function to ItemSet -Timeline.prototype._onSelectItem = function (event) { - if (!this.options.selectable) return; + if (!dom) return; // when destroyed - var ctrlKey = event.gesture.srcEvent && event.gesture.srcEvent.ctrlKey; - var shiftKey = event.gesture.srcEvent && event.gesture.srcEvent.shiftKey; - if (ctrlKey || shiftKey) { - this._onMultiSelectItem(event); - return; - } + // update class names + dom.root.className = 'vis timeline root ' + options.orientation; - var oldSelection = this.getSelection(); + // update root width and height options + dom.root.style.maxHeight = util.option.asSize(options.maxHeight, ''); + dom.root.style.minHeight = util.option.asSize(options.minHeight, ''); + dom.root.style.width = util.option.asSize(options.width, ''); - var item = ItemSet.itemFromTarget(event); - var selection = item ? [item.id] : []; - this.setSelection(selection); + // calculate border widths + props.border.left = (dom.centerContainer.offsetWidth - dom.centerContainer.clientWidth) / 2; + props.border.right = props.border.left; + props.border.top = (dom.centerContainer.offsetHeight - dom.centerContainer.clientHeight) / 2; + props.border.bottom = props.border.top; + var borderRootHeight= dom.root.offsetHeight - dom.root.clientHeight; + var borderRootWidth = dom.root.offsetWidth - dom.root.clientWidth; - var newSelection = this.getSelection(); + // calculate the heights. If any of the side panels is empty, we set the height to + // minus the border width, such that the border will be invisible + props.center.height = dom.center.offsetHeight; + props.left.height = dom.left.offsetHeight; + props.right.height = dom.right.offsetHeight; + props.top.height = dom.top.clientHeight || -props.border.top; + props.bottom.height = dom.bottom.clientHeight || -props.border.bottom; - // if selection is changed, emit a select event - if (!util.equalArray(oldSelection, newSelection)) { - this.emit('select', { - items: this.getSelection() - }); + // TODO: compensate borders when any of the panels is empty. + + // apply auto height + // TODO: only calculate autoHeight when needed (else we cause an extra reflow/repaint of the DOM) + var contentHeight = Math.max(props.left.height, props.center.height, props.right.height); + var autoHeight = props.top.height + contentHeight + props.bottom.height + + borderRootHeight + props.border.top + props.border.bottom; + dom.root.style.height = util.option.asSize(options.height, autoHeight + 'px'); + + // calculate heights of the content panels + props.root.height = dom.root.offsetHeight; + props.background.height = props.root.height - borderRootHeight; + var containerHeight = props.root.height - props.top.height - props.bottom.height - + borderRootHeight; + props.centerContainer.height = containerHeight; + props.leftContainer.height = containerHeight; + props.rightContainer.height = props.leftContainer.height; + + // calculate the widths of the panels + props.root.width = dom.root.offsetWidth; + props.background.width = props.root.width - borderRootWidth; + props.left.width = dom.leftContainer.clientWidth || -props.border.left; + props.leftContainer.width = props.left.width; + props.right.width = dom.rightContainer.clientWidth || -props.border.right; + props.rightContainer.width = props.right.width; + var centerWidth = props.root.width - props.left.width - props.right.width - borderRootWidth; + props.center.width = centerWidth; + props.centerContainer.width = centerWidth; + props.top.width = centerWidth; + props.bottom.width = centerWidth; + + // resize the panels + dom.background.style.height = props.background.height + 'px'; + dom.backgroundVertical.style.height = props.background.height + 'px'; + dom.backgroundHorizontal.style.height = props.centerContainer.height + 'px'; + dom.centerContainer.style.height = props.centerContainer.height + 'px'; + dom.leftContainer.style.height = props.leftContainer.height + 'px'; + dom.rightContainer.style.height = props.rightContainer.height + 'px'; + + dom.background.style.width = props.background.width + 'px'; + dom.backgroundVertical.style.width = props.centerContainer.width + 'px'; + dom.backgroundHorizontal.style.width = props.background.width + 'px'; + dom.centerContainer.style.width = props.center.width + 'px'; + dom.top.style.width = props.top.width + 'px'; + dom.bottom.style.width = props.bottom.width + 'px'; + + // reposition the panels + dom.background.style.left = '0'; + dom.background.style.top = '0'; + dom.backgroundVertical.style.left = props.left.width + 'px'; + dom.backgroundVertical.style.top = '0'; + dom.backgroundHorizontal.style.left = '0'; + dom.backgroundHorizontal.style.top = props.top.height + 'px'; + dom.centerContainer.style.left = props.left.width + 'px'; + dom.centerContainer.style.top = props.top.height + 'px'; + dom.leftContainer.style.left = '0'; + dom.leftContainer.style.top = props.top.height + 'px'; + dom.rightContainer.style.left = (props.left.width + props.center.width) + 'px'; + dom.rightContainer.style.top = props.top.height + 'px'; + dom.top.style.left = props.left.width + 'px'; + dom.top.style.top = '0'; + dom.bottom.style.left = props.left.width + 'px'; + dom.bottom.style.top = (props.top.height + props.centerContainer.height) + 'px'; + + // update the scrollTop, feasible range for the offset can be changed + // when the height of the Timeline or of the contents of the center changed + this._updateScrollTop(); + + // reposition the scrollable contents + var offset = this.props.scrollTop; + if (options.orientation == 'bottom') { + offset += Math.max(this.props.centerContainer.height - this.props.center.height, 0); } + dom.center.style.left = '0'; + dom.center.style.top = offset + 'px'; + dom.left.style.left = '0'; + dom.left.style.top = offset + 'px'; + dom.right.style.left = '0'; + dom.right.style.top = offset + 'px'; - event.stopPropagation(); + // show shadows when vertical scrolling is available + var visibilityTop = this.props.scrollTop == 0 ? 'hidden' : ''; + var visibilityBottom = this.props.scrollTop == this.props.scrollTopMin ? 'hidden' : ''; + dom.shadowTop.style.visibility = visibilityTop; + dom.shadowBottom.style.visibility = visibilityBottom; + dom.shadowTopLeft.style.visibility = visibilityTop; + dom.shadowBottomLeft.style.visibility = visibilityBottom; + dom.shadowTopRight.style.visibility = visibilityTop; + dom.shadowBottomRight.style.visibility = visibilityBottom; + + // redraw all components + this.components.forEach(function (component) { + resized = component.redraw() || resized; + }); + if (resized) { + // keep repainting until all sizes are settled + this.redraw(); + } }; +// TODO: deprecated since version 1.1.0, remove some day +Timeline.prototype.repaint = function () { + throw new Error('Function repaint is deprecated. Use redraw instead.'); +}; + /** - * Handle creation and updates of an item on double tap - * @param event + * Convert a position on screen (pixels) to a datetime + * @param {int} x Position on the screen in pixels + * @return {Date} time The datetime the corresponds with given position x * @private */ -Timeline.prototype._onAddItem = function (event) { - if (!this.options.selectable) return; - if (!this.options.editable.add) return; +// TODO: move this function to Range +Timeline.prototype._toTime = function(x) { + var conversion = this.range.conversion(this.props.center.width); + return new Date(x / conversion.scale + conversion.offset); +}; - var me = this, - item = ItemSet.itemFromTarget(event); +/** + * Convert a datetime (Date object) into a position on the screen + * @param {Date} time A date + * @return {int} x The position on the screen in pixels which corresponds + * with the given date. + * @private + */ +// TODO: move this function to Range +Timeline.prototype._toScreen = function(time) { + var conversion = this.range.conversion(this.props.center.width); + return (time.valueOf() - conversion.offset) * conversion.scale; +}; - if (item) { - // update item - - // execute async handler to update the item (or cancel it) - var itemData = me.itemsData.get(item.id); // get a clone of the data from the dataset - this.options.onUpdate(itemData, function (itemData) { - if (itemData) { - me.itemsData.update(itemData); - } - }); +/** + * Initialize watching when option autoResize is true + * @private + */ +Timeline.prototype._initAutoResize = function () { + if (this.options.autoResize == true) { + this._startAutoResize(); } else { - // add item - var xAbs = vis.util.getAbsoluteLeft(this.contentPanel.frame); - var x = event.gesture.center.pageX - xAbs; - var newItem = { - start: this.timeAxis.snap(this._toTime(x)), - content: 'new item' - }; + this._stopAutoResize(); + } +}; - // when default type is a range, add a default end date to the new item - if (this.options.type === 'range' || this.options.type == 'rangeoverflow') { - newItem.end = this.timeAxis.snap(this._toTime(x + this.rootPanel.width / 5)); - } +/** + * Watch for changes in the size of the container. On resize, the Panel will + * automatically redraw itself. + * @private + */ +Timeline.prototype._startAutoResize = function () { + var me = this; - var id = util.randomUUID(); - newItem[this.itemsData.fieldId] = id; + this._stopAutoResize(); - var group = ItemSet.groupFromTarget(event); - if (group) { - newItem.group = group.groupId; + this._onResize = function() { + if (me.options.autoResize != true) { + // stop watching when the option autoResize is changed to false + me._stopAutoResize(); + return; } - // execute async handler to customize (or cancel) adding an item - this.options.onAdd(newItem, function (item) { - if (item) { - me.itemsData.add(newItem); - // TODO: need to trigger a repaint? + if (me.dom.root) { + // check whether the frame is resized + if ((me.dom.root.clientWidth != me.props.lastWidth) || + (me.dom.root.clientHeight != me.props.lastHeight)) { + me.props.lastWidth = me.dom.root.clientWidth; + me.props.lastHeight = me.dom.root.clientHeight; + + me.emit('change'); } - }); + } + }; + + // add event listener to window resize + util.addEventListener(window, 'resize', this._onResize); + + this.watchTimer = setInterval(this._onResize, 1000); +}; + +/** + * Stop watching for a resize of the frame. + * @private + */ +Timeline.prototype._stopAutoResize = function () { + if (this.watchTimer) { + clearInterval(this.watchTimer); + this.watchTimer = undefined; } + + // remove event listener on window.resize + util.removeEventListener(window, 'resize', this._onResize); + this._onResize = null; }; /** - * Handle selecting/deselecting multiple items when holding an item + * Start moving the timeline vertically * @param {Event} event * @private */ -// TODO: move this function to ItemSet -Timeline.prototype._onMultiSelectItem = function (event) { - if (!this.options.selectable) return; +Timeline.prototype._onTouch = function (event) { + this.touch.allowDragging = true; +}; - var selection, - item = ItemSet.itemFromTarget(event); +/** + * Start moving the timeline vertically + * @param {Event} event + * @private + */ +Timeline.prototype._onPinch = function (event) { + this.touch.allowDragging = false; +}; - if (item) { - // multi select items - selection = this.getSelection(); // current selection - var index = selection.indexOf(item.id); - if (index == -1) { - // item is not yet selected -> select it - selection.push(item.id); - } - else { - // item is already selected -> deselect it - selection.splice(index, 1); - } - this.setSelection(selection); +/** + * Start moving the timeline vertically + * @param {Event} event + * @private + */ +Timeline.prototype._onDragStart = function (event) { + this.touch.initialScrollTop = this.props.scrollTop; +}; - this.emit('select', { - items: this.getSelection() - }); +/** + * Move the timeline vertically + * @param {Event} event + * @private + */ +Timeline.prototype._onDrag = function (event) { + // refuse to drag when we where pinching to prevent the timeline make a jump + // when releasing the fingers in opposite order from the touch screen + if (!this.touch.allowDragging) return; - event.stopPropagation(); + var delta = event.gesture.deltaY; + + var oldScrollTop = this._getScrollTop(); + var newScrollTop = this._setScrollTop(this.touch.initialScrollTop + delta); + + if (newScrollTop != oldScrollTop) { + this.redraw(); // TODO: this causes two redraws when dragging, the other is triggered by rangechange already } }; /** - * Convert a position on screen (pixels) to a datetime - * @param {int} x Position on the screen in pixels - * @return {Date} time The datetime the corresponds with given position x + * Apply a scrollTop + * @param {Number} scrollTop + * @returns {Number} scrollTop Returns the applied scrollTop * @private */ -Timeline.prototype._toTime = function _toTime(x) { - var conversion = this.range.conversion(this.mainPanel.width); - return new Date(x / conversion.scale + conversion.offset); +Timeline.prototype._setScrollTop = function (scrollTop) { + this.props.scrollTop = scrollTop; + this._updateScrollTop(); + return this.props.scrollTop; }; /** - * Convert a datetime (Date object) into a position on the screen - * @param {Date} time A date - * @return {int} x The position on the screen in pixels which corresponds - * with the given date. + * Update the current scrollTop when the height of the containers has been changed + * @returns {Number} scrollTop Returns the applied scrollTop * @private */ -Timeline.prototype._toScreen = function _toScreen(time) { - var conversion = this.range.conversion(this.mainPanel.width); - return (time.valueOf() - conversion.offset) * conversion.scale; +Timeline.prototype._updateScrollTop = function () { + // recalculate the scrollTopMin + var scrollTopMin = Math.min(this.props.centerContainer.height - this.props.center.height, 0); // is negative or zero + if (scrollTopMin != this.props.scrollTopMin) { + // in case of bottom orientation, change the scrollTop such that the contents + // do not move relative to the time axis at the bottom + if (this.options.orientation == 'bottom') { + this.props.scrollTop += (scrollTopMin - this.props.scrollTopMin); + } + this.props.scrollTopMin = scrollTopMin; + } + + // limit the scrollTop to the feasible scroll range + if (this.props.scrollTop > 0) this.props.scrollTop = 0; + if (this.props.scrollTop < scrollTopMin) this.props.scrollTop = scrollTopMin; + + return this.props.scrollTop; +}; + +/** + * Get the current scrollTop + * @returns {number} scrollTop + * @private + */ +Timeline.prototype._getScrollTop = function () { + return this.props.scrollTop; };