VulpesOS/gaia/apps/calendar/js/interval_tree.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

588 lines
12 KiB
JavaScript
Executable file

/**
* The interval tree is a structure
* designed and optimized for storing
* (possibly) overlapping intervals in
* such a way that we can optimally query them.
*
* To store an item in the tree the item
* must have `START`, `END` and `_id` properties
* both properties must be numeric and start
* must always be < then end. ID should be unique
* to each interval though it should be possible
* to store multiple intervals with the same
* start/end times.
*
* Trees should be created by providing an
* array of items sorted by their start
* times.
*
*
* // _start is a constant see below
* var list = [
* { start: 100, end: 200 },
* { start: 120, end: 150 },
* ...
* ];
*
* var tree = new Calendar.Node(list);
*
*
* The tree _should-be_ dynamic and you can add and
* remove items from the tree.
*
* For the present the tree will rebuild itself
* after add/removal before the next query operation.
*
*
* tree.add({ start: 0, end: 50 });
*
* // record should be === to record
* // stored in the tree.
* tree.remove(savedRecord);
*
*
* TODO: Implement self-balancing and real tree mutations.
*/
define(function(require, exports, module) {
'use strict';
var binsearch = require('common/binsearch');
var compare = require('common/compare');
var debug = require('common/debug')('interval_tree');
const START = '_startDateMS';
const END = '_endDateMS';
function compareObjectStart(a, b) {
return compare(a[START], b[START]);
}
function compareObjectEnd(a, b) {
return compare(a[END], b[END]);
}
IntervalTree.compareObjectStart = compareObjectStart;
IntervalTree.compareObjectEnd = compareObjectEnd;
/**
* Internal function to create an endpoint
* list. Assumed this is the array to insert
* endpoint values into.
*
* @param {Object} item object with [START] & [END] properties.
*/
function buildEndpoints(item) {
/*jshint validthis:true */
addOrdered(item[START], this);
addOrdered(item[END], this);
}
function Node() {
this.list = [];
}
Node.prototype = {
/**
* Node to the left null or a node.
*/
left: null,
/**
* Node to the right null or a node.
*/
right: null,
/**
* List of objects that overlap current node.
*
* @type Array
*/
list: null,
/**
* Center point of this node.
*/
median: null,
/**
* Highest value of this node & subnodes.
*/
max: null,
/**
* Iterates through matching items.
* Will be roughly ordered by start
* time but exact sort order is not
* guaranteed.
*
* @param {Calendar.Timespan} span timespan.
* @param {Function} callback first argument is matching
* record second is the node in the
* tree where record was found.
*/
traverse: function(span, fn) {
if (this.left && (span.start < this.median)) {
this.left.traverse(span, fn);
}
var i = 0;
var len = this.list.length;
var item;
for (; i < len; i++) {
item = this.list[i];
if (item[START] > span.end) {
break;
}
if (span.overlaps(item[START], item[END])) {
fn(item, this);
}
}
if (this.right && span.end > this.median) {
this.right.traverse(span, fn);
}
},
/**
* Find all overlapping records via a Calendar.Timespan.
*
* @param {Calendar.Timespan} span timespan.
* @return {Array} results sorted by start time.
*/
query: function(span) {
var results = [];
var seen = Object.create(null);
this.traverse(span, function(item) {
///XXX: we probably want to order
// these by start time via bin search
// order by sort at the end.
if (!seen[item._id]) {
results.push(item);
}
});
return results;
}
};
IntervalTree.Node = Node;
/**
* Start point for creation of the tree
* this is optimized for the most balanced tree
* when possible. The idea is the majority of
* operations will be read and traversal.
*
* NOTE: Currently we discard the tree and
* mark the object as synced=false after mutations
* The next time the object is queried the tree is rebuilt.
*/
function IntervalTree(list) {
if (typeof(list) === 'undefined') {
this.items = [];
} else {
this.items = list.concat([]);
}
/**
* Properties to index by when fields are added.
*/
this._indexes = Object.create(null);
// method aggregates
this._indexOnAdd = [];
this._indexOnRemove = [];
this.byId = Object.create(null);
this.synced = false;
}
module.exports = IntervalTree;
IntervalTree.prototype = {
START: START,
END: END,
build: function() {
if (!this.synced) {
this.rootNode = this._nodeFromList(this.items);
this.synced = true;
}
},
toArray: function() {
// create a copy of the items array
return this.items.concat();
},
_getId: function(item) {
return item._id;
},
/**
* Returns all values in the given index.
*
* @param {String} property name of index.
* @param {String} [value] to filter index on (optional).
* @return {Null|Array}
*/
index: function(property, value) {
var items = this._indexes[property];
if (items && value) {
return items[value];
}
return items;
},
/**
* Create index on property.
*
* @param {String} property to index on.
*/
createIndex: function(property) {
var index = this._indexes[property] = {};
// remember this will be invoked later with the context
// of |this| always...
function addToIndex(object) {
var value = object[property];
// create array for index possibilities
if (!index[value]) {
index[value] = [];
}
// and push single object to index
index[value].push(object);
}
function removeFromIndex(object) {
// object given should always be same instance stored.
var value = object[property];
var valueGroup = index[value];
if (valueGroup) {
var idx = valueGroup.indexOf(object);
valueGroup.splice(idx, 1);
if (valueGroup.length === 0) {
delete index[value];
}
}
}
this._indexOnAdd.push(addToIndex);
this._indexOnRemove.push(removeFromIndex);
},
/**
* Adds an item to the tree
*/
add: function(item) {
var id = this._getId(item);
if (id in this.byId) {
return;
}
if (!item[START] && item.startDate) {
item[START] = item.startDate.valueOf();
}
if (!item[END] && item.endDate) {
item[END] = item.endDate.valueOf();
}
if (!item[START] || !item[END]) {
return debug('Invalid input skipping record: ', item);
}
var idx = binsearch.insert(
this.items,
item,
compareObjectStart
);
this.items.splice(idx, 0, item);
this.byId[id] = item;
this.synced = false;
var len = this._indexOnAdd.length;
for (var i = 0; i < len; i++) {
this._indexOnAdd[i].call(this, item);
}
return item;
},
indexOf: function(item) {
var query = {};
query[START] = item[START];
var idx = binsearch.find(
this.items,
query,
compareObjectStart
);
var prevIdx;
var current;
if (idx !== null) {
// we want to start idx at earliest
// point in list that matches start time.
// When there are multiple start times
// the binsearch may start us at any point
// in the range of matching items.
// Iterate backwards.
if (idx > 0) {
prevIdx = idx;
while (prevIdx > -1) {
prevIdx--;
current = this.items[prevIdx];
if (current && current[START] === item[START]) {
if (current === item) {
return prevIdx;
}
} else {
break;
}
}
}
//Iterate forwards.
current = this.items[idx];
while (current) {
if (current === item) {
return idx;
}
current = this.items[++idx];
if (!current || current[START] !== item[START]) {
return null;
}
}
}
return null;
},
/**
* Removes an item to the list.
* Must be same === item as as the
* one you are trying to remove.
*/
remove: function(item) {
var idx = this.indexOf(item);
if (idx !== null) {
this._removeIds(this.items[idx]);
this.items.splice(idx, 1);
this.synced = false;
return true;
}
return false;
},
_removeIds: function(item) {
if (Array.isArray(item)) {
item.forEach(this._removeIds, this);
} else {
var len = this._indexOnRemove.length;
for (var i = 0; i < len; i++) {
this._indexOnRemove[i].call(this, item);
}
var id = this._getId(item);
delete this.byId[id];
}
},
/**
* Remove all intervals that start
* after a particular time.
*
* // assume we have a list of the
* // following intervals
* 1-2 4-10 5-10 6-8 8-9
*
* tree.removeFutureIntervals(5);
*
* // now we have: 1-2, 4-10 5-10
*
* @param {Numeric} start last start point.
*/
removeFutureIntervals: function(start) {
var query = {};
query[START] = start;
var idx = binsearch.insert(
this.items,
query,
compareObjectStart
);
var max = this.items.length - 1;
if (!this.items[idx]) {
return;
}
// for duplicate values we need
// to find the very last one
// before the split point.
while (this.items[idx] && this.items[idx][START] <= start) {
idx++;
if (idx === max) {
break;
}
}
this.synced = false;
var remove = this.items.splice(
idx, this.items.length - idx
);
this._removeIds(remove);
return remove;
},
/**
* Remove all intervals that end
* before a particular time.
*
* For example is you have:
*
* // assume we have a list of the
* // following intervals
* 1-10, 2-3, 3-4, 4-5
*
* tree.removePastIntervals(4);
*
* // now we have: 1-10, 4-5
*
* @param {Numeric} end last end point.
*/
removePastIntervals: function(end) {
// 1. first re-sort to end dates.
var items = this.items.sort(compareObjectEnd);
// 2. find index of the last date ending
// on or before end.
var endQuery = {};
endQuery[END] = end;
var idx = binsearch.insert(
items,
endQuery,
compareObjectEnd
);
var max = items.length - 1;
if (!items[idx]) {
return;
}
// for duplicate values we need
// to find the very last one
// before the split point.
while (items[idx][END] <= end) {
idx++;
if (idx === max) {
break;
}
}
this.synced = false;
var remove = items.slice(0, idx);
this.items = items.slice(idx).sort(
compareObjectStart
);
this._removeIds(remove);
return remove;
},
/**
* Executes a query on all nodes.
* Rebuilds tree if in unclean state first.
*
* @param {Calendar.Timespan} span timespan.
*/
query: function(span) {
this.build();
return this.rootNode.query(span);
},
_nodeFromList: function(list) {
var rootNode = new Node();
var left = [];
var right = [];
var median;
var endpoints = [];
//1. build endpoints to calculate median
// endpoints are the middle value of
// all start/end points in the current list.
list.forEach(buildEndpoints, endpoints);
median = rootNode.median = endpoints[Math.floor(endpoints.length / 2)];
list.forEach(function(item) {
if (item[END] < median) {
left.push(item);
} else if (item[START] > median) {
right.push(item);
} else {
rootNode.list.push(item);
}
}, this);
// recurse - create left/right nodes.
if (left.length) {
rootNode.left = this._nodeFromList(left);
}
if (right.length) {
rootNode.right = this._nodeFromList(right);
}
return rootNode;
}
};
/**
* Internal function to add an item
* to an array via binary search insert.
* Keeps items in order as they are inserted.
*
* @private
*/
function addOrdered(item, array) {
var idx = binsearch.insert(
array,
item,
compare
);
array.splice(idx, 0, item);
}
});