|
|
- /**
- * An ItemSet holds a set of items and ranges which can be displayed in a
- * range. The width is determined by the parent of the ItemSet, and the height
- * is determined by the size of the items.
- * @param {Component} parent
- * @param {Component[]} [depends] Components on which this components depends
- * (except for the parent)
- * @param {Object} [options] See ItemSet.setOptions for the available
- * options.
- * @constructor ItemSet
- * @extends Panel
- */
- // TODO: improve performance by replacing all Array.forEach with a for loop
- function ItemSet(parent, depends, options) {
- this.id = util.randomUUID();
- this.parent = parent;
- this.depends = depends;
-
- // event listeners
- this.eventListeners = {
- dragstart: this._onDragStart.bind(this),
- drag: this._onDrag.bind(this),
- dragend: this._onDragEnd.bind(this)
- };
-
- // one options object is shared by this itemset and all its items
- this.options = options || {};
- this.defaultOptions = {
- type: 'box',
- align: 'center',
- orientation: 'bottom',
- margin: {
- axis: 20,
- item: 10
- },
- padding: 5
- };
-
- this.dom = {};
-
- var me = this;
- this.itemsData = null; // DataSet
- this.range = null; // Range or Object {start: number, end: number}
-
- // data change listeners
- this.listeners = {
- 'add': function (event, params, senderId) {
- if (senderId != me.id) {
- me._onAdd(params.items);
- }
- },
- 'update': function (event, params, senderId) {
- if (senderId != me.id) {
- me._onUpdate(params.items);
- }
- },
- 'remove': function (event, params, senderId) {
- if (senderId != me.id) {
- me._onRemove(params.items);
- }
- }
- };
-
- this.items = {}; // object with an Item for every data item
- this.orderedItems = []; // ordered items
- this.visibleItems = []; // visible, ordered items
- this.visibleItemsStart = 0; // start index of visible items in this.orderedItems
- this.visibleItemsEnd = 0; // start index of visible items in this.orderedItems
- this.selection = []; // list with the ids of all selected nodes
- this.queue = {}; // queue with id/actions: 'add', 'update', 'delete'
- this.stack = new Stack(this, Object.create(this.options));
- this.conversion = null;
-
- this.touchParams = {}; // stores properties while dragging
-
- // TODO: ItemSet should also attach event listeners for rangechange and rangechanged, like timeaxis
- }
-
- ItemSet.prototype = new Panel();
-
- // available item types will be registered here
- ItemSet.types = {
- box: ItemBox,
- range: ItemRange,
- rangeoverflow: ItemRangeOverflow,
- point: ItemPoint
- };
-
- /**
- * Set options for the ItemSet. Existing options will be extended/overwritten.
- * @param {Object} [options] The following options are available:
- * {String | function} [className]
- * class name for the itemset
- * {String} [type]
- * Default type for the items. Choose from 'box'
- * (default), 'point', or 'range'. The default
- * Style can be overwritten by individual items.
- * {String} align
- * Alignment for the items, only applicable for
- * ItemBox. Choose 'center' (default), 'left', or
- * 'right'.
- * {String} orientation
- * Orientation of the item set. Choose 'top' or
- * 'bottom' (default).
- * {Number} margin.axis
- * Margin between the axis and the items in pixels.
- * Default is 20.
- * {Number} margin.item
- * Margin between items in pixels. Default is 10.
- * {Number} padding
- * Padding of the contents of an item in pixels.
- * Must correspond with the items css. Default is 5.
- * {Function} snap
- * Function to let items snap to nice dates when
- * dragging items.
- */
- ItemSet.prototype.setOptions = Component.prototype.setOptions;
-
-
-
- /**
- * Set controller for this component
- * @param {Controller | null} controller
- */
- ItemSet.prototype.setController = function setController (controller) {
- var event;
-
- // unregister old event listeners
- if (this.controller) {
- for (event in this.eventListeners) {
- if (this.eventListeners.hasOwnProperty(event)) {
- this.controller.off(event, this.eventListeners[event]);
- }
- }
- }
-
- this.controller = controller || null;
-
- // register new event listeners
- if (this.controller) {
- for (event in this.eventListeners) {
- if (this.eventListeners.hasOwnProperty(event)) {
- this.controller.on(event, this.eventListeners[event]);
- }
- }
- }
- };
-
- /**
- * Set range (start and end).
- * @param {Range | Object} range A Range or an object containing start and end.
- */
- ItemSet.prototype.setRange = function setRange(range) {
- if (!(range instanceof Range) && (!range || !range.start || !range.end)) {
- throw new TypeError('Range must be an instance of Range, ' +
- 'or an object containing start and end.');
- }
- this.range = range;
- };
-
- /**
- * Set selected items by their id. Replaces the current selection
- * 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.
- */
- ItemSet.prototype.setSelection = function setSelection(ids) {
- var i, ii, id, item, selection;
-
- if (ids) {
- if (!Array.isArray(ids)) {
- throw new TypeError('Array expected');
- }
-
- // unselect currently selected items
- for (i = 0, ii = this.selection.length; i < ii; i++) {
- id = this.selection[i];
- item = this.items[id];
- if (item) item.unselect();
- }
-
- // select items
- this.selection = [];
- for (i = 0, ii = ids.length; i < ii; i++) {
- id = ids[i];
- item = this.items[id];
- if (item) {
- this.selection.push(id);
- item.select();
- }
- }
-
- if (this.controller) {
- this.requestRepaint();
- }
- }
- };
-
- /**
- * Get the selected items by their id
- * @return {Array} ids The ids of the selected items
- */
- ItemSet.prototype.getSelection = function getSelection() {
- return this.selection.concat([]);
- };
-
- /**
- * Deselect a selected item
- * @param {String | Number} id
- * @private
- */
- ItemSet.prototype._deselect = function _deselect(id) {
- var selection = this.selection;
- for (var i = 0, ii = selection.length; i < ii; i++) {
- if (selection[i] == id) { // non-strict comparison!
- selection.splice(i, 1);
- break;
- }
- }
- };
-
- /**
- * Repaint the component
- * @return {Boolean} changed
- */
- ItemSet.prototype.repaint = function repaint() {
- var changed = 0,
- update = util.updateProperty,
- asSize = util.option.asSize,
- options = this.options,
- orientation = this.getOption('orientation'),
- frame = this.frame;
-
- this._updateConversion();
-
- if (!frame) {
- frame = document.createElement('div');
- frame.className = 'itemset';
- frame['timeline-itemset'] = this;
-
- var className = options.className;
- if (className) {
- util.addClassName(frame, util.option.asString(className));
- }
-
- // create background panel
- var background = document.createElement('div');
- background.className = 'background';
- frame.appendChild(background);
- this.dom.background = background;
-
- // create foreground panel
- var foreground = document.createElement('div');
- foreground.className = 'foreground';
- frame.appendChild(foreground);
- this.dom.foreground = foreground;
-
- // create axis panel
- var axis = document.createElement('div');
- axis.className = 'itemset-axis';
- //frame.appendChild(axis);
- this.dom.axis = axis;
-
- this.frame = frame;
- changed += 1;
- }
-
- if (!this.parent) {
- throw new Error('Cannot repaint itemset: no parent attached');
- }
- var parentContainer = this.parent.getContainer();
- if (!parentContainer) {
- throw new Error('Cannot repaint itemset: parent has no container element');
- }
- if (!frame.parentNode) {
- parentContainer.appendChild(frame);
- changed += 1;
- }
- if (!this.dom.axis.parentNode) {
- parentContainer.appendChild(this.dom.axis);
- changed += 1;
- }
-
- // reposition frame
- changed += update(frame.style, 'left', asSize(options.left, '0px'));
- changed += update(frame.style, 'top', asSize(options.top, '0px'));
- changed += update(frame.style, 'width', asSize(options.width, '100%'));
- changed += update(frame.style, 'height', asSize(options.height, this.height + 'px'));
-
- // reposition axis
- changed += update(this.dom.axis.style, 'left', asSize(options.left, '0px'));
- changed += update(this.dom.axis.style, 'width', asSize(options.width, '100%'));
- if (orientation == 'bottom') {
- changed += update(this.dom.axis.style, 'top', (this.height + this.top) + 'px');
- }
- else { // orientation == 'top'
- changed += update(this.dom.axis.style, 'top', this.top + 'px');
- }
-
- // find start of visible items
- var start = Math.min(this.visibleItemsStart, Math.max(this.orderedItems.length - 1, 0));
- var item = this.orderedItems[start];
- while (item && item.isVisible() && start > 0) {
- start--;
- item = this.orderedItems[start];
- }
- while (item && !item.isVisible()) {
- if (item.displayed) item.hide();
-
- start++;
- item = this.orderedItems[start];
- }
- this.visibleItemsStart = start;
-
- // find end of visible items
- var end = Math.max(Math.min(this.visibleItemsEnd, this.orderedItems.length), this.visibleItemsStart);
- item = this.orderedItems[end];
- while (item && item.isVisible()) {
- end++;
- item = this.orderedItems[end];
- }
- item = this.orderedItems[end - 1];
- while (item && !item.isVisible() && end > 0) {
- if (item.displayed) item.hide();
-
- end--;
- item = this.orderedItems[end - 1];
- }
- this.visibleItemsEnd = end;
-
- console.log('visible items', start, end); // TODO: cleanup
-
- this.visibleItems = this.orderedItems.slice(start, end);
-
- // check whether zoomed (in that case we need to re-stack everything)
- var visibleInterval = this.range.end - this.range.start;
- var zoomed = this.visibleInterval != visibleInterval;
- this.visibleInterval = visibleInterval;
-
- // show visible items
- for (var i = 0, ii = this.visibleItems.length; i < ii; i++) {
- var item = this.visibleItems[i];
-
- if (!item.displayed) item.show();
- if (zoomed) item.top = null; // reset stacking position
-
- // reposition item horizontally
- item.repositionX();
- }
-
- // reposition visible items vertically
- // TODO: improve stacking, when moving the timeline to the right, update stacking in backward order
- this.stack.stack(this.visibleItems);
- for (var i = 0, ii = this.visibleItems.length; i < ii; i++) {
- this.visibleItems[i].repositionY();
- }
-
- return false;
- };
-
- /**
- * Get the foreground container element
- * @return {HTMLElement} foreground
- */
- ItemSet.prototype.getForeground = function getForeground() {
- return this.dom.foreground;
- };
-
- /**
- * Get the background container element
- * @return {HTMLElement} background
- */
- ItemSet.prototype.getBackground = function getBackground() {
- return this.dom.background;
- };
-
- /**
- * Get the axis container element
- * @return {HTMLElement} axis
- */
- ItemSet.prototype.getAxis = function getAxis() {
- return this.dom.axis;
- };
-
- /**
- * Reflow the component
- * @return {Boolean} resized
- */
- ItemSet.prototype.reflow = function reflow () {
- var changed = 0,
- options = this.options,
- marginAxis = options.margin && options.margin.axis || this.defaultOptions.margin.axis,
- marginItem = options.margin && options.margin.item || this.defaultOptions.margin.item,
- update = util.updateProperty,
- asNumber = util.option.asNumber,
- asSize = util.option.asSize,
- frame = this.frame;
-
- if (frame) {
- this._updateConversion();
-
- /* TODO
- util.forEach(this.items, function (item) {
- changed += item.reflow();
- });
- */
-
- // TODO: stack.update should be triggered via an event, in stack itself
- // TODO: only update the stack when there are changed items
- //this.stack.update();
-
- var maxHeight = asNumber(options.maxHeight);
- var fixedHeight = (asSize(options.height) != null);
- var height;
- if (fixedHeight) {
- height = frame.offsetHeight;
- }
- else {
- // height is not specified, determine the height from the height and positioned items
- var visibleItems = this.visibleItems; // TODO: not so nice way to get the filtered items
- if (visibleItems.length) { // TODO: calculate max height again
- var min = visibleItems[0].top;
- var max = visibleItems[0].top + visibleItems[0].height;
- util.forEach(visibleItems, function (item) {
- min = Math.min(min, item.top);
- max = Math.max(max, (item.top + item.height));
- });
- height = (max - min) + marginAxis + marginItem;
- }
- else {
- height = marginAxis + marginItem;
- }
- }
- if (maxHeight != null) {
- height = Math.min(height, maxHeight);
- }
- height = 200; // TODO: cleanup
- changed += update(this, 'height', height);
-
- // calculate height from items
- changed += update(this, 'top', frame.offsetTop);
- changed += update(this, 'left', frame.offsetLeft);
- changed += update(this, 'width', frame.offsetWidth);
- }
- else {
- changed += 1;
- }
-
- return false;
- };
-
- /**
- * Hide this component from the DOM
- * @return {Boolean} changed
- */
- ItemSet.prototype.hide = function hide() {
- var changed = false;
-
- // remove the DOM
- if (this.frame && this.frame.parentNode) {
- this.frame.parentNode.removeChild(this.frame);
- changed = true;
- }
- if (this.dom.axis && this.dom.axis.parentNode) {
- this.dom.axis.parentNode.removeChild(this.dom.axis);
- changed = true;
- }
-
- return changed;
- };
-
- /**
- * Set items
- * @param {vis.DataSet | null} items
- */
- ItemSet.prototype.setItems = function setItems(items) {
- var me = this,
- ids,
- oldItemsData = this.itemsData;
-
- // replace the dataset
- if (!items) {
- this.itemsData = null;
- }
- else if (items instanceof DataSet || items instanceof DataView) {
- this.itemsData = items;
- }
- else {
- throw new TypeError('Data must be an instance of DataSet');
- }
-
- if (oldItemsData) {
- // unsubscribe from old dataset
- util.forEach(this.listeners, function (callback, event) {
- oldItemsData.unsubscribe(event, callback);
- });
-
- // remove all drawn items
- ids = oldItemsData.getIds();
- this._onRemove(ids);
- }
-
- if (this.itemsData) {
- // subscribe to new dataset
- var id = this.id;
- util.forEach(this.listeners, function (callback, event) {
- me.itemsData.on(event, callback, id);
- });
-
- // draw all new items
- ids = this.itemsData.getIds();
- this._onAdd(ids);
- }
- };
-
- /**
- * Get the current items items
- * @returns {vis.DataSet | null}
- */
- ItemSet.prototype.getItems = function getItems() {
- return this.itemsData;
- };
-
- /**
- * Remove an item by its id
- * @param {String | Number} id
- */
- ItemSet.prototype.removeItem = function removeItem (id) {
- var item = this.itemsData.get(id),
- dataset = this._myDataSet();
-
- if (item) {
- // confirm deletion
- this.options.onRemove(item, function (item) {
- if (item) {
- dataset.remove(item);
- }
- });
- }
- };
-
- /**
- * Handle updated items
- * @param {Number[]} ids
- * @private
- */
- ItemSet.prototype._onUpdate = function _onUpdate(ids) {
- var me = this,
- defaultOptions = {
- type: 'box',
- align: 'center',
- orientation: 'bottom',
- margin: {
- axis: 20,
- item: 10
- },
- padding: 5
- };
-
- ids.forEach(function (id) {
- var itemData = me.itemsData.get(id),
- item = items[id],
- type = itemData.type ||
- (itemData.start && itemData.end && 'range') ||
- options.type ||
- 'box';
-
- var constructor = ItemSet.types[type];
-
- // TODO: how to handle items with invalid data? hide them and give a warning? or throw an error?
- if (item) {
- // update item
- if (!constructor || !(item instanceof constructor)) {
- // item type has changed, hide and delete the item
- item.hide();
- item = null;
- }
- else {
- item.data = itemData; // TODO: create a method item.setData ?
- }
- }
-
- if (!item) {
- // create item
- if (constructor) {
- item = new constructor(me, itemData, options, defaultOptions);
- item.id = id;
- }
- else {
- throw new TypeError('Unknown item type "' + type + '"');
- }
- }
-
- me.items[id] = item;
- });
-
- this._order();
- this.repaint();
- };
-
- /**
- * Handle added items
- * @param {Number[]} ids
- * @private
- */
- ItemSet.prototype._onAdd = ItemSet.prototype._onUpdate;
-
- /**
- * Handle removed items
- * @param {Number[]} ids
- * @private
- */
- ItemSet.prototype._onRemove = function _onRemove(ids) {
- var me = this;
- ids.forEach(function (id) {
- var item = me.items[id];
- if (item) {
- item.hide(); // TODO: only hide when displayed
- delete me.items[id];
- delete me.visibleItems[id];
- }
- });
-
- this._order();
- };
-
- /**
- * Order the items
- * @private
- */
- ItemSet.prototype._order = function _order() {
- // reorder the items
- this.orderedItems = this.stack.order(this.items);
- }
-
- /**
- * Calculate the scale and offset to convert a position on screen to the
- * corresponding date and vice versa.
- * After the method _updateConversion is executed once, the methods toTime
- * and toScreen can be used.
- * @private
- */
- ItemSet.prototype._updateConversion = function _updateConversion() {
- var range = this.range;
- if (!range) {
- throw new Error('No range configured');
- }
-
- if (range.conversion) {
- this.conversion = range.conversion(this.width);
- }
- else {
- this.conversion = Range.conversion(range.start, range.end, this.width);
- }
- };
-
- /**
- * Convert a position on screen (pixels) to a datetime
- * Before this method can be used, the method _updateConversion must be
- * executed once.
- * @param {int} x Position on the screen in pixels
- * @return {Date} time The datetime the corresponds with given position x
- */
- ItemSet.prototype.toTime = function toTime(x) {
- var conversion = this.conversion;
- return new Date(x / conversion.scale + conversion.offset);
- };
-
- /**
- * Convert a datetime (Date object) into a position on the screen
- * Before this method can be used, the method _updateConversion must be
- * executed once.
- * @param {Date} time A date
- * @return {int} x The position on the screen in pixels which corresponds
- * with the given date.
- */
- ItemSet.prototype.toScreen = function toScreen(time) {
- var conversion = this.conversion;
- return (time.valueOf() - conversion.offset) * conversion.scale;
- };
-
- /**
- * Start dragging the selected events
- * @param {Event} event
- * @private
- */
- ItemSet.prototype._onDragStart = function (event) {
- if (!this.options.editable) {
- return;
- }
-
- var item = ItemSet.itemFromTarget(event),
- me = this;
-
- if (item && item.selected) {
- var dragLeftItem = event.target.dragLeftItem;
- var dragRightItem = event.target.dragRightItem;
-
- if (dragLeftItem) {
- this.touchParams.itemProps = [{
- item: dragLeftItem,
- start: item.data.start.valueOf()
- }];
- }
- else if (dragRightItem) {
- this.touchParams.itemProps = [{
- item: dragRightItem,
- end: item.data.end.valueOf()
- }];
- }
- else {
- this.touchParams.itemProps = this.getSelection().map(function (id) {
- var item = me.items[id];
- var props = {
- item: item
- };
-
- if ('start' in item.data) {
- props.start = item.data.start.valueOf()
- }
- if ('end' in item.data) {
- props.end = item.data.end.valueOf()
- }
-
- return props;
- });
- }
-
- event.stopPropagation();
- }
- };
-
- /**
- * Drag selected items
- * @param {Event} event
- * @private
- */
- ItemSet.prototype._onDrag = function (event) {
- if (this.touchParams.itemProps) {
- var snap = this.options.snap || null,
- deltaX = event.gesture.deltaX,
- offset = deltaX / this.conversion.scale;
-
- // move
- this.touchParams.itemProps.forEach(function (props) {
- if ('start' in props) {
- var start = new Date(props.start + offset);
- props.item.data.start = snap ? snap(start) : start;
- }
- if ('end' in props) {
- var end = new Date(props.end + offset);
- props.item.data.end = snap ? snap(end) : end;
- }
- });
-
- // TODO: implement onMoving handler
-
- // TODO: implement dragging from one group to another
-
- this.requestReflow();
-
- event.stopPropagation();
- }
- };
-
- /**
- * End of dragging selected items
- * @param {Event} event
- * @private
- */
- ItemSet.prototype._onDragEnd = function (event) {
- if (this.touchParams.itemProps) {
- // prepare a change set for the changed items
- var changes = [],
- me = this,
- dataset = this._myDataSet(),
- type;
-
- this.touchParams.itemProps.forEach(function (props) {
- var id = props.item.id,
- item = me.itemsData.get(id);
-
- var changed = false;
- if ('start' in props.item.data) {
- changed = (props.start != props.item.data.start.valueOf());
- item.start = util.convert(props.item.data.start, dataset.convert['start']);
- }
- if ('end' in props.item.data) {
- changed = changed || (props.end != props.item.data.end.valueOf());
- item.end = util.convert(props.item.data.end, dataset.convert['end']);
- }
-
- // only apply changes when start or end is actually changed
- if (changed) {
- me.options.onMove(item, function (item) {
- if (item) {
- // apply changes
- changes.push(item);
- }
- else {
- // restore original values
- if ('start' in props) props.item.data.start = props.start;
- if ('end' in props) props.item.data.end = props.end;
- me.requestReflow();
- }
- });
- }
- });
- this.touchParams.itemProps = null;
-
- // apply the changes to the data (if there are changes)
- if (changes.length) {
- dataset.update(changes);
- }
-
- event.stopPropagation();
- }
- };
-
- /**
- * Find an item from an event target:
- * searches for the attribute 'timeline-item' in the event target's element tree
- * @param {Event} event
- * @return {Item | null} item
- */
- ItemSet.itemFromTarget = function itemFromTarget (event) {
- var target = event.target;
- while (target) {
- if (target.hasOwnProperty('timeline-item')) {
- return target['timeline-item'];
- }
- target = target.parentNode;
- }
-
- return null;
- };
-
- /**
- * Find the ItemSet from an event target:
- * searches for the attribute 'timeline-itemset' in the event target's element tree
- * @param {Event} event
- * @return {ItemSet | null} item
- */
- ItemSet.itemSetFromTarget = function itemSetFromTarget (event) {
- var target = event.target;
- while (target) {
- if (target.hasOwnProperty('timeline-itemset')) {
- return target['timeline-itemset'];
- }
- target = target.parentNode;
- }
-
- return null;
- };
-
- /**
- * Find the DataSet to which this ItemSet is connected
- * @returns {null | DataSet} dataset
- * @private
- */
- ItemSet.prototype._myDataSet = function _myDataSet() {
- // find the root DataSet
- var dataset = this.itemsData;
- while (dataset instanceof DataView) {
- dataset = dataset.data;
- }
- return dataset;
- };
|