diff --git a/docs/timeline/index.html b/docs/timeline/index.html index 70f82050..cc9e495e 100644 --- a/docs/timeline/index.html +++ b/docs/timeline/index.html @@ -588,12 +588,50 @@ function (option, path) { + + groupEditable + boolean or Object + false + If true, the groups in the timeline can be manipulated. See also the callbacks onAddGroup, onMoveGroup, and onRemoveGroup. When groupEditable is an object, one can enable or disable individual manipulation actions. + The editing of groups follows the same principles as for items, see section Editing Items for a detailed explanation. + + + + groupEditable.add + boolean + false + If true, new groups can be created in the Timeline. For now adding new groups is done by the user. + + + groupEditable.remove + boolean + false + If true, groups can be deleted. For now removing groups is done by the user. + + + groupEditable.order + boolean + false + If true, groups can be dragged to change their order. Only applicable when the Timeline has groups. For this option to work properly the groupOrder and groupOrderSwap options have to be set as well. + + groupOrder String or Function - none + 'order' Order the groups by a field name or custom sort function. - By default, groups are not ordered. + By default, groups are ordered by an attribute order (if set). + + + + + groupOrderSwap + Function + none + Swaps the positions of two groups. If groups have a custom order (via groupOrder) and groups are configured to be reorderable (via groupEditable.order), the user has to provide a function that swaps the positions of two given groups. + If this option is not set, the default implementation assumes that groups hold an attribute order which values are changed. The signature of the groupOrderWap function is: +
function groupOrderSwap(fromGroup: Object, toGroup: Object, groups: DataSet)
+ The first to arguments hold the groups of which the positions are to be swapped and the third argument holds the DataSet with all groups. @@ -738,6 +776,14 @@ function (option, path) { Callback function triggered when an item is about to be added: when the user double taps an empty space in the Timeline. See section Editing Items for more information. Only applicable when both options selectable and editable.add are set true. + + + onAddGroup + function + none + Callback function triggered when a group is about to be added. The signature and semantics are the same as for onAdd. + + onUpdate @@ -754,6 +800,14 @@ function (option, path) { Callback function triggered when an item has been moved: after the user has dragged the item to an other position. See section Editing Items for more information. Only applicable when both options selectable and editable.updateTime or editable.updateGroup are set true. + + + onMoveGroup + function + none + Callback function triggered when a group has been moved: after the user has dragged the group to an other position. The signature and semantics are the same as for onMove. + + onMoving @@ -770,6 +824,14 @@ function (option, path) { Callback function triggered when an item is about to be removed: when the user tapped the delete button on the top right of a selected item. See section Editing Items for more information. Only applicable when both options selectable and editable.remove are set true. + + + onRemoveGroup + function + none + Callback function triggered when a group is about to be removed. The signature and semantics are the same as for onRemove. + + order @@ -1266,6 +1328,15 @@ timeline.off('select', onSelect); + + groupDragged + + Passes the id of the dragged group. + + Fired after the dragging of a group is finished. + + + rangechange diff --git a/examples/timeline/groups/groupsEditable.html b/examples/timeline/groups/groupsEditable.html new file mode 100644 index 00000000..8fce1410 --- /dev/null +++ b/examples/timeline/groups/groupsEditable.html @@ -0,0 +1,315 @@ + + + + Timeline | Editable Groups + + + + + + + + +

+ This example demonstrates editable groups (for now only reordering). +

+
+ + + + \ No newline at end of file diff --git a/lib/timeline/component/Group.js b/lib/timeline/component/Group.js index ae60270a..51761959 100644 --- a/lib/timeline/component/Group.js +++ b/lib/timeline/component/Group.js @@ -47,7 +47,11 @@ function Group (groupId, data, itemSet) { */ Group.prototype._create = function() { var label = document.createElement('div'); - label.className = 'vis-label'; + if (this.itemSet.options.groupEditable.order) { + label.className = 'vis-label draggable'; + } else { + label.className = 'vis-label'; + } this.dom.label = label; var inner = document.createElement('div'); diff --git a/lib/timeline/component/ItemSet.js b/lib/timeline/component/ItemSet.js index e4d73789..be30cf67 100644 --- a/lib/timeline/component/ItemSet.js +++ b/lib/timeline/component/ItemSet.js @@ -34,7 +34,12 @@ function ItemSet(body, options) { }, align: 'auto', // alignment of box items stack: true, - groupOrder: null, + groupOrderSwap: function(fromGroup, toGroup, groups) { + var targetOrder = toGroup.order; + toGroup.order = fromGroup.order; + fromGroup.order = targetOrder; + }, + groupOrder: 'order', selectable: true, multiselect: false, @@ -46,6 +51,12 @@ function ItemSet(body, options) { remove: false }, + groupEditable: { + order: false, + add: false, + remove: false + }, + snap: TimeStep.snap, onAdd: function (item, callback) { @@ -63,6 +74,15 @@ function ItemSet(body, options) { onMoving: function (item, callback) { callback(item); }, + onAddGroup: function (item, callback) { + callback(item); + }, + onMoveGroup: function (item, callback) { + callback(item); + }, + onRemoveGroup: function (item, callback) { + callback(item); + }, margin: { item: { @@ -127,6 +147,7 @@ function ItemSet(body, options) { this.stackDirty = true; // if true, all items will be restacked on next redraw this.touchParams = {}; // stores properties while dragging + this.groupTouchParams = {}; // create the HTML DOM this._create(); @@ -209,6 +230,12 @@ ItemSet.prototype._create = function(){ // add item on doubletap this.hammer.on('doubletap', this._onAddItem.bind(this)); + this.groupHammer = new Hammer(this.body.dom.leftContainer); + this.groupHammer.on('panstart', this._onGroupDragStart.bind(this)); + this.groupHammer.on('panmove', this._onGroupDrag.bind(this)); + this.groupHammer.on('panend', this._onGroupDragEnd.bind(this)); + this.groupHammer.get('pan').set({threshold:5, direction:30}); + // attach to the DOM this.show(); }; @@ -280,7 +307,7 @@ ItemSet.prototype._create = function(){ ItemSet.prototype.setOptions = function(options) { if (options) { // copy all options that we know - var fields = ['type', 'align', 'order', 'stack', 'selectable', 'multiselect', 'groupOrder', 'dataAttributes', 'template','groupTemplate','hide', 'snap']; + var fields = ['type', 'align', 'order', 'stack', 'selectable', 'multiselect', 'groupOrder', 'dataAttributes', 'template', 'groupTemplate', 'hide', 'snap', 'groupOrderSwap']; util.selectiveExtend(fields, this.options, options); if ('orientation' in options) { @@ -323,6 +350,17 @@ ItemSet.prototype.setOptions = function(options) { util.selectiveExtend(['updateTime', 'updateGroup', 'add', 'remove'], this.options.editable, options.editable); } } + + if ('groupEditable' in options) { + if (typeof options.groupEditable === 'boolean') { + this.options.groupEditable.order = options.groupEditable; + this.options.groupEditable.add = options.groupEditable; + this.options.groupEditable.remove = options.groupEditable; + } + else if (typeof options.groupEditable === 'object') { + util.selectiveExtend(['order', 'add', 'remove'], this.options.groupEditable, options.groupEditable); + } + } // callback functions var addCallback = (function (name) { @@ -334,7 +372,7 @@ ItemSet.prototype.setOptions = function(options) { this.options[name] = fn; } }).bind(this); - ['onAdd', 'onUpdate', 'onRemove', 'onMove', 'onMoving'].forEach(addCallback); + ['onAdd', 'onUpdate', 'onRemove', 'onMove', 'onMoving', 'onAddGroup', 'onMoveGroup', 'onRemoveGroup'].forEach(addCallback); // force the itemSet to refresh: options like orientation and margins may be changed this.markDirty(); @@ -1427,6 +1465,182 @@ ItemSet.prototype._onDragEnd = function (event) { } }; +ItemSet.prototype._onGroupDragStart = function (event) { + if (this.options.groupEditable.order) { + this.groupTouchParams.group = this.groupFromTarget(event); + + if (this.groupTouchParams.group) { + event.stopPropagation(); + + this.groupTouchParams.originalOrder = this.groupsData.getIds({ + order: this.options.groupOrder + }); + } + } +} + +ItemSet.prototype._onGroupDrag = function (event) { + if (this.options.groupEditable.order && this.groupTouchParams.group) { + event.stopPropagation(); + + // drag from one group to another + var group = this.groupFromTarget(event); + + // try to avoid toggling when groups differ in height + if (group && group.height != this.groupTouchParams.group.height) { + var movingUp = (group.top < this.groupTouchParams.group.top); + var clientY = event.center ? event.center.y : event.clientY; + var targetGroupTop = util.getAbsoluteTop(group.dom.foreground); + var draggedGroupHeight = this.groupTouchParams.group.height; + if (movingUp) { + // skip swapping the groups when the dragged group is not below clientY afterwards + if (targetGroupTop + draggedGroupHeight < clientY) { + return; + } + } else { + var targetGroupHeight = group.height; + // skip swapping the groups when the dragged group is not below clientY afterwards + if (targetGroupTop + targetGroupHeight - draggedGroupHeight > clientY) { + return; + } + } + } + + if (group && group != this.groupTouchParams.group) { + var groupsData = this.groupsData; + var targetGroup = groupsData.get(group.groupId); + var draggedGroup = groupsData.get(this.groupTouchParams.group.groupId); + + // switch groups + if (draggedGroup && targetGroup) { + this.options.groupOrderSwap(draggedGroup, targetGroup, this.groupsData); + this.groupsData.update(draggedGroup); + this.groupsData.update(targetGroup); + } + + // fetch current order of groups + var newOrder = this.groupsData.getIds({ + order: this.options.groupOrder + }); + + // in case of changes since _onGroupDragStart + if (!util.equalArray(newOrder, this.groupTouchParams.originalOrder)) { + var groupsData = this.groupsData; + var origOrder = this.groupTouchParams.originalOrder; + var draggedId = this.groupTouchParams.group.groupId; + var numGroups = Math.min(origOrder.length, newOrder.length); + var curPos = 0; + var newOffset = 0; + var orgOffset = 0; + while (curPos < numGroups) { + // as long as the groups are where they should be step down along the groups order + while ((curPos+newOffset) < numGroups + && (curPos+orgOffset) < numGroups + && newOrder[curPos+newOffset] == origOrder[curPos+orgOffset]) { + curPos++; + } + + // all ok + if (curPos+newOffset >= numGroups) { + break; + } + + // not all ok + // if dragged group was move upwards everything below should have an offset + if (newOrder[curPos+newOffset] == draggedId) { + newOffset = 1; + continue; + } + // if dragged group was move downwards everything above should have an offset + else if (origOrder[curPos+orgOffset] == draggedId) { + orgOffset = 1; + continue; + } + // found a group (apart from dragged group) that has the wrong position -> switch with the + // group at the position where other one should be, fix index arrays and continue + else { + var slippedPosition = newOrder.indexOf(origOrder[curPos+orgOffset]) + var switchGroup = groupsData.get(newOrder[curPos+newOffset]); + var shouldBeGroup = groupsData.get(origOrder[curPos+orgOffset]); + this.options.groupOrderSwap(switchGroup, shouldBeGroup, groupsData); + groupsData.update(switchGroup); + groupsData.update(shouldBeGroup); + + var switchGroupId = newOrder[curPos+newOffset]; + newOrder[curPos+newOffset] = origOrder[curPos+orgOffset]; + newOrder[slippedPosition] = switchGroupId; + + curPos++; + } + } + } + + } + } +} + +ItemSet.prototype._onGroupDragEnd = function (event) { + if (this.options.groupEditable.order && this.groupTouchParams.group) { + event.stopPropagation(); + + // update existing group + var me = this; + var id = me.groupTouchParams.group.groupId; + var dataset = me.groupsData.getDataSet(); + var groupData = util.extend({}, dataset.get(id)); // clone the data + me.options.onMoveGroup(groupData, function (groupData) { + if (groupData) { + // apply changes + groupData[dataset._fieldId] = id; // ensure the group contains its id (can be undefined) + dataset.update(groupData); + } + else { + + // fetch current order of groups + var newOrder = dataset.getIds({ + order: me.options.groupOrder + }); + + // restore original order + if (!util.equalArray(newOrder, me.groupTouchParams.originalOrder)) { + var origOrder = me.groupTouchParams.originalOrder; + var numGroups = Math.min(origOrder.length, newOrder.length); + var curPos = 0; + while (curPos < numGroups) { + // as long as the groups are where they should be step down along the groups order + while (curPos < numGroups && newOrder[curPos] == origOrder[curPos]) { + curPos++; + } + + // all ok + if (curPos >= numGroups) { + break; + } + + // found a group that has the wrong position -> switch with the + // group at the position where other one should be, fix index arrays and continue + var slippedPosition = newOrder.indexOf(origOrder[curPos]) + var switchGroup = dataset.get(newOrder[curPos]); + var shouldBeGroup = dataset.get(origOrder[curPos]); + me.options.groupOrderSwap(switchGroup, shouldBeGroup, dataset); + groupsData.update(switchGroup); + groupsData.update(shouldBeGroup); + + var switchGroupId = newOrder[curPos]; + newOrder[curPos] = origOrder[curPos]; + newOrder[slippedPosition] = switchGroupId; + + curPos++; + } + } + + } + }); + + me.body.emitter.emit('groupDragged', { groupId: id }); + } +} + /** * Handle selecting/deselecting an item when tapping it * @param {Event} event diff --git a/lib/timeline/component/css/labelset.css b/lib/timeline/component/css/labelset.css index c2690e97..3aeabd10 100644 --- a/lib/timeline/component/css/labelset.css +++ b/lib/timeline/component/css/labelset.css @@ -21,6 +21,10 @@ border-bottom: 1px solid #bfbfbf; } +.vis-labelset .vis-label.draggable { + cursor: pointer; +} + .vis-labelset .vis-label:last-child { border-bottom: none; } diff --git a/lib/timeline/optionsTimeline.js b/lib/timeline/optionsTimeline.js index edfb4554..18a1471d 100644 --- a/lib/timeline/optionsTimeline.js +++ b/lib/timeline/optionsTimeline.js @@ -64,6 +64,13 @@ let allOptions = { }, moment: {'function': 'function'}, groupOrder: {string, 'function': 'function'}, + groupEditable: { + add: {boolean, 'undefined': 'undefined'}, + remove: {boolean, 'undefined': 'undefined'}, + order: {boolean, 'undefined': 'undefined'}, + __type__: {boolean, object} + }, + groupOrderSwap: {'function': 'function'}, height: {string, number}, hiddenDates: {object, array}, locale:{string}, @@ -91,6 +98,9 @@ let allOptions = { onMove: {'function': 'function'}, onMoving: {'function': 'function'}, onRemove: {'function': 'function'}, + onAddGroup: {'function': 'function'}, + onMoveGroup: {'function': 'function'}, + onRemoveGroup: {'function': 'function'}, order: {'function': 'function'}, orientation: { axis: {string,'undefined': 'undefined'}, @@ -158,6 +168,7 @@ let configureOptions = { }, //groupOrder: {string, 'function': 'function'}, + groupsDraggable: false, height: '', //hiddenDates: {object, array}, locale: '',