array_data_model.js revision 1320f92c476a1ad9d19dba2a48c72b75566198e9
1// Copyright (c) 2012 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * @fileoverview This is a data model representin
7 */
8
9// The include directives are put into Javascript-style comments to prevent
10// parsing errors in non-flattened mode. The flattener still sees them.
11// Note that this makes the flattener to comment out the first line of the
12// included file but that's all right since any javascript file should start
13// with a copyright comment anyway.
14
15//<include src="../../assert.js">
16
17cr.define('cr.ui', function() {
18  /** @const */ var EventTarget = cr.EventTarget;
19
20  /**
21   * A data model that wraps a simple array and supports sorting by storing
22   * initial indexes of elements for each position in sorted array.
23   * @param {!Array} array The underlying array.
24   * @constructor
25   * @extends {cr.EventTarget}
26   */
27  function ArrayDataModel(array) {
28    this.array_ = array;
29    this.indexes_ = [];
30    this.compareFunctions_ = {};
31
32    for (var i = 0; i < array.length; i++) {
33      this.indexes_.push(i);
34    }
35  }
36
37  ArrayDataModel.prototype = {
38    __proto__: EventTarget.prototype,
39
40    /**
41     * The length of the data model.
42     * @type {number}
43     */
44    get length() {
45      return this.array_.length;
46    },
47
48    /**
49     * Returns the item at the given index.
50     * This implementation returns the item at the given index in the sorted
51     * array.
52     * @param {number} index The index of the element to get.
53     * @return {*} The element at the given index.
54     */
55    item: function(index) {
56      if (index >= 0 && index < this.length)
57        return this.array_[this.indexes_[index]];
58      return undefined;
59    },
60
61    /**
62     * Returns compare function set for given field.
63     * @param {string} field The field to get compare function for.
64     * @return {function(*, *): number} Compare function set for given field.
65     */
66    compareFunction: function(field) {
67      return this.compareFunctions_[field];
68    },
69
70    /**
71     * Sets compare function for given field.
72     * @param {string} field The field to set compare function.
73     * @param {function(*, *): number} compareFunction Compare function to set
74     *     for given field.
75     */
76    setCompareFunction: function(field, compareFunction) {
77      if (!this.compareFunctions_) {
78        this.compareFunctions_ = {};
79      }
80      this.compareFunctions_[field] = compareFunction;
81    },
82
83    /**
84     * Returns true if the field has a compare function.
85     * @param {string} field The field to check.
86     * @return {boolean} True if the field is sortable.
87     */
88    isSortable: function(field) {
89      return this.compareFunctions_ && field in this.compareFunctions_;
90    },
91
92    /**
93     * Returns current sort status.
94     * @return {!Object} Current sort status.
95     */
96    get sortStatus() {
97      if (this.sortStatus_) {
98        return this.createSortStatus(
99            this.sortStatus_.field, this.sortStatus_.direction);
100      } else {
101        return this.createSortStatus(null, null);
102      }
103    },
104
105    /**
106     * Returns the first matching item.
107     * @param {*} item The item to find.
108     * @param {number=} opt_fromIndex If provided, then the searching start at
109     *     the {@code opt_fromIndex}.
110     * @return {number} The index of the first found element or -1 if not found.
111     */
112    indexOf: function(item, opt_fromIndex) {
113      for (var i = opt_fromIndex || 0; i < this.indexes_.length; i++) {
114        if (item === this.item(i))
115          return i;
116      }
117      return -1;
118    },
119
120    /**
121     * Returns an array of elements in a selected range.
122     * @param {number=} opt_from The starting index of the selected range.
123     * @param {number=} opt_to The ending index of selected range.
124     * @return {Array} An array of elements in the selected range.
125     */
126    slice: function(opt_from, opt_to) {
127      var arr = this.array_;
128      return this.indexes_.slice(opt_from, opt_to).map(
129          function(index) { return arr[index] });
130    },
131
132    /**
133     * This removes and adds items to the model.
134     * This dispatches a splice event.
135     * This implementation runs sort after splice and creates permutation for
136     * the whole change.
137     * @param {number} index The index of the item to update.
138     * @param {number} deleteCount The number of items to remove.
139     * @param {...*} var_args The items to add.
140     * @return {!Array} An array with the removed items.
141     */
142    splice: function(index, deleteCount, var_args) {
143      var addCount = arguments.length - 2;
144      var newIndexes = [];
145      var deletePermutation = [];
146      var deletedItems = [];
147      var newArray = [];
148      index = Math.min(index, this.indexes_.length);
149      deleteCount = Math.min(deleteCount, this.indexes_.length - index);
150      // Copy items before the insertion point.
151      for (var i = 0; i < index; i++) {
152        newIndexes.push(newArray.length);
153        deletePermutation.push(i);
154        newArray.push(this.array_[this.indexes_[i]]);
155      }
156      // Delete items.
157      for (; i < index + deleteCount; i++) {
158        deletePermutation.push(-1);
159        deletedItems.push(this.array_[this.indexes_[i]]);
160      }
161      // Insert new items instead deleted ones.
162      for (var j = 0; j < addCount; j++) {
163        newIndexes.push(newArray.length);
164        newArray.push(arguments[j + 2]);
165      }
166      // Copy items after the insertion point.
167      for (; i < this.indexes_.length; i++) {
168        newIndexes.push(newArray.length);
169        deletePermutation.push(i - deleteCount + addCount);
170        newArray.push(this.array_[this.indexes_[i]]);
171      }
172
173      this.indexes_ = newIndexes;
174
175      this.array_ = newArray;
176
177      // TODO(arv): Maybe unify splice and change events?
178      var spliceEvent = new Event('splice');
179      spliceEvent.removed = deletedItems;
180      spliceEvent.added = Array.prototype.slice.call(arguments, 2);
181
182      var status = this.sortStatus;
183      // if sortStatus.field is null, this restores original order.
184      var sortPermutation = this.doSort_(this.sortStatus.field,
185                                         this.sortStatus.direction);
186      if (sortPermutation) {
187        var splicePermutation = deletePermutation.map(function(element) {
188          return element != -1 ? sortPermutation[element] : -1;
189        });
190        this.dispatchPermutedEvent_(splicePermutation);
191        spliceEvent.index = sortPermutation[index];
192      } else {
193        this.dispatchPermutedEvent_(deletePermutation);
194        spliceEvent.index = index;
195      }
196
197      this.dispatchEvent(spliceEvent);
198
199      // If real sorting is needed, we should first call prepareSort (data may
200      // change), and then sort again.
201      // Still need to finish the sorting above (including events), so
202      // list will not go to inconsistent state.
203      if (status.field)
204        this.delayedSort_(status.field, status.direction);
205
206      return deletedItems;
207    },
208
209    /**
210     * Appends items to the end of the model.
211     *
212     * This dispatches a splice event.
213     *
214     * @param {...*} var_args The items to append.
215     * @return {number} The new length of the model.
216     */
217    push: function(var_args) {
218      var args = Array.prototype.slice.call(arguments);
219      args.unshift(this.length, 0);
220      this.splice.apply(this, args);
221      return this.length;
222    },
223
224    /**
225     * Updates the existing item with the new item.
226     *
227     * The existing item and the new item are regarded as the same item and the
228     * permutation tracks these indexes.
229     *
230     * @param {*} oldItem Old item that is contained in the model. If the item
231     *     is not found in the model, the method call is just ignored.
232     * @param {*} newItem New item.
233     */
234    replaceItem: function(oldItem, newItem) {
235      var index = this.indexOf(oldItem);
236      if (index < 0)
237        return;
238      this.array_[this.indexes_[index]] = newItem;
239      this.updateIndex(index);
240    },
241
242    /**
243     * Use this to update a given item in the array. This does not remove and
244     * reinsert a new item.
245     * This dispatches a change event.
246     * This runs sort after updating.
247     * @param {number} index The index of the item to update.
248     */
249    updateIndex: function(index) {
250      this.updateIndexes([index]);
251    },
252
253    /**
254     * Notifies of update of the items in the array. This does not remove and
255     * reinsert new items.
256     * This dispatches one or more change events.
257     * This runs sort after updating.
258     * @param {Array.<number>} indexes The index list of items to update.
259     */
260    updateIndexes: function(indexes) {
261      indexes.forEach(function(index) {
262        assert(index >= 0 && index < this.length, 'Invalid index');
263      }, this);
264
265      for (var i = 0; i < indexes.length; i++) {
266        var e = new Event('change');
267        e.index = indexes[i];
268        this.dispatchEvent(e);
269      }
270
271      if (this.sortStatus.field) {
272        var status = this.sortStatus;
273        var sortPermutation = this.doSort_(this.sortStatus.field,
274                                           this.sortStatus.direction);
275        if (sortPermutation)
276          this.dispatchPermutedEvent_(sortPermutation);
277        // We should first call prepareSort (data may change), and then sort.
278        // Still need to finish the sorting above (including events), so
279        // list will not go to inconsistent state.
280        this.delayedSort_(status.field, status.direction);
281      }
282    },
283
284    /**
285     * Creates sort status with given field and direction.
286     * @param {?string} field Sort field.
287     * @param {?string} direction Sort direction.
288     * @return {!Object} Created sort status.
289     */
290    createSortStatus: function(field, direction) {
291      return {
292        field: field,
293        direction: direction
294      };
295    },
296
297    /**
298     * Called before a sort happens so that you may fetch additional data
299     * required for the sort.
300     *
301     * @param {string} field Sort field.
302     * @param {function()} callback The function to invoke when preparation
303     *     is complete.
304     */
305    prepareSort: function(field, callback) {
306      callback();
307    },
308
309    /**
310     * Sorts data model according to given field and direction and dispathes
311     * sorted event with delay. If no need to delay, use sort() instead.
312     * @param {string} field Sort field.
313     * @param {string} direction Sort direction.
314     * @private
315     */
316    delayedSort_: function(field, direction) {
317      var self = this;
318      setTimeout(function() {
319        // If the sort status has been changed, sorting has already done
320        // on the change event.
321        if (field == self.sortStatus.field &&
322            direction == self.sortStatus.direction) {
323          self.sort(field, direction);
324        }
325      }, 0);
326    },
327
328    /**
329     * Sorts data model according to given field and direction and dispathes
330     * sorted event.
331     * @param {string} field Sort field.
332     * @param {string} direction Sort direction.
333     */
334    sort: function(field, direction) {
335      var self = this;
336
337      this.prepareSort(field, function() {
338        var sortPermutation = self.doSort_(field, direction);
339        if (sortPermutation)
340          self.dispatchPermutedEvent_(sortPermutation);
341        self.dispatchSortEvent_();
342      });
343    },
344
345    /**
346     * Sorts data model according to given field and direction.
347     * @param {string} field Sort field.
348     * @param {string} direction Sort direction.
349     * @private
350     */
351    doSort_: function(field, direction) {
352      var compareFunction = this.sortFunction_(field, direction);
353      var positions = [];
354      for (var i = 0; i < this.length; i++) {
355        positions[this.indexes_[i]] = i;
356      }
357      var sorted = this.indexes_.every(function(element, index, array) {
358        return index == 0 || compareFunction(element, array[index - 1]) >= 0;
359      });
360      if (!sorted)
361        this.indexes_.sort(compareFunction);
362      this.sortStatus_ = this.createSortStatus(field, direction);
363      var sortPermutation = [];
364      var changed = false;
365      for (var i = 0; i < this.length; i++) {
366        if (positions[this.indexes_[i]] != i)
367          changed = true;
368        sortPermutation[positions[this.indexes_[i]]] = i;
369      }
370      if (changed)
371        return sortPermutation;
372      return null;
373    },
374
375    dispatchSortEvent_: function() {
376      var e = new Event('sorted');
377      this.dispatchEvent(e);
378    },
379
380    dispatchPermutedEvent_: function(permutation) {
381      var e = new Event('permuted');
382      e.permutation = permutation;
383      e.newLength = this.length;
384      this.dispatchEvent(e);
385    },
386
387    /**
388     * Creates compare function for the field.
389     * Returns the function set as sortFunction for given field
390     * or default compare function
391     * @param {string} field Sort field.
392     * @return {function(*, *): number} Compare function.
393     * @private
394     */
395    createCompareFunction_: function(field) {
396      var compareFunction =
397          this.compareFunctions_ ? this.compareFunctions_[field] : null;
398      var defaultValuesCompareFunction = this.defaultValuesCompareFunction;
399      if (compareFunction) {
400        return compareFunction;
401      } else {
402        return function(a, b) {
403          return defaultValuesCompareFunction.call(null, a[field], b[field]);
404        }
405      }
406    },
407
408    /**
409     * Creates compare function for given field and direction.
410     * @param {string} field Sort field.
411     * @param {string} direction Sort direction.
412     * @private
413     */
414    sortFunction_: function(field, direction) {
415      var compareFunction = null;
416      if (field !== null)
417        compareFunction = this.createCompareFunction_(field);
418      var dirMultiplier = direction == 'desc' ? -1 : 1;
419
420      return function(index1, index2) {
421        var item1 = this.array_[index1];
422        var item2 = this.array_[index2];
423
424        var compareResult = 0;
425        if (typeof(compareFunction) === 'function')
426          compareResult = compareFunction.call(null, item1, item2);
427        if (compareResult != 0)
428          return dirMultiplier * compareResult;
429        return dirMultiplier * this.defaultValuesCompareFunction(index1,
430                                                                 index2);
431      }.bind(this);
432    },
433
434    /**
435     * Default compare function.
436     */
437    defaultValuesCompareFunction: function(a, b) {
438      // We could insert i18n comparisons here.
439      if (a < b)
440        return -1;
441      if (a > b)
442        return 1;
443      return 0;
444    }
445  };
446
447  return {
448    ArrayDataModel: ArrayDataModel
449  };
450});
451