UNPKG

vis-timeline

Version:

Create a fully customizable, interactive timeline with items and ranges.

306 lines (267 loc) 10.7 kB
import ClusterItem from './item/ClusterItem'; const UNGROUPED = '__ungrouped__'; // reserved group id for ungrouped items const BACKGROUND = '__background__'; // reserved group id for background items without group export const ReservedGroupIds = { UNGROUPED, BACKGROUND } /** * An Cluster generator generates cluster items */ export default class ClusterGenerator { /** * @param {ItemSet} itemSet itemsSet instance * @constructor ClusterGenerator */ constructor(itemSet) { this.itemSet = itemSet; this.groups = {}; this.cache = {}; this.cache[-1] = []; } /** * @param {Object} itemData Object containing parameters start content, className. * @param {{toScreen: function, toTime: function}} conversion * Conversion functions from time to screen and vice versa * @param {Object} [options] Configuration options * @return {Object} newItem */ createClusterItem(itemData, conversion, options) { const newItem = new ClusterItem(itemData, conversion, options); return newItem; } /** * Set the items to be clustered. * This will clear cached clusters. * @param {Item[]} items * @param {Object} [options] Available options: * {boolean} applyOnChangedLevel * If true (default), the changed data is applied * as soon the cluster level changes. If false, * The changed data is applied immediately */ setItems(items, options) { this.items = items || []; this.dataChanged = true; this.applyOnChangedLevel = false; if (options && options.applyOnChangedLevel) { this.applyOnChangedLevel = options.applyOnChangedLevel; } } /** * Update the current data set: clear cache, and recalculate the clustering for * the current level */ updateData() { this.dataChanged = true; this.applyOnChangedLevel = false; } /** * Cluster the items which are too close together * @param {array} oldClusters * @param {number} scale The scale of the current window : (windowWidth / (endDate - startDate)) * @param {{maxItems: number, clusterCriteria: function, titleTemplate: string}} options * @return {array} clusters */ getClusters(oldClusters, scale, options) { let { maxItems, clusterCriteria } = typeof options === "boolean" ? {} : options; if (!clusterCriteria) { clusterCriteria = () => true; } maxItems = maxItems || 1; let level = -1; let granularity = 2; let timeWindow = 0; if (scale > 0) { if (scale >= 1) { return []; } level = Math.abs(Math.round(Math.log(100 / scale) / Math.log(granularity))); timeWindow = Math.abs(Math.pow(granularity, level)); } // clear the cache when and re-generate groups the data when needed. if (this.dataChanged) { const levelChanged = (level != this.cacheLevel); const applyDataNow = this.applyOnChangedLevel ? levelChanged : true; if (applyDataNow) { this._dropLevelsCache(); this._filterData(); } } this.cacheLevel = level; let clusters = this.cache[level]; if (!clusters) { clusters = []; for (let groupName in this.groups) { if (this.groups.hasOwnProperty(groupName)) { const items = this.groups[groupName]; const iMax = items.length; let i = 0; while (i < iMax) { // find all items around current item, within the timeWindow let item = items[i]; let neighbors = 1; // start at 1, to include itself) // loop through items left from the current item let j = i - 1; while (j >= 0 && (item.center - items[j].center) < timeWindow / 2) { if (!items[j].cluster && clusterCriteria(item.data, items[j].data)) { neighbors++; } j--; } // loop through items right from the current item let k = i + 1; while (k < items.length && (items[k].center - item.center) < timeWindow / 2) { if (clusterCriteria(item.data, items[k].data)) { neighbors++; } k++; } // loop through the created clusters let l = clusters.length - 1; while (l >= 0 && (item.center - clusters[l].center) < timeWindow) { if (item.group == clusters[l].group && clusterCriteria(item.data, clusters[l].data)) { neighbors++; } l--; } // aggregate until the number of items is within maxItems if (neighbors > maxItems) { // too busy in this window. const num = neighbors - maxItems + 1; const clusterItems = []; // append the items to the cluster, // and calculate the average start for the cluster let m = i; while (clusterItems.length < num && m < items.length) { if (clusterCriteria(items[m].data, items[m].data)) { clusterItems.push(items[m]); } m++; } const groupId = this.itemSet.getGroupId(item.data); const group = this.itemSet.groups[groupId] || this.itemSet.groups[ReservedGroupIds.UNGROUPED]; let cluster = this._getClusterForItems(clusterItems, group, oldClusters, options); clusters.push(cluster); i += num; } else { delete item.cluster; i += 1; } } } } this.cache[level] = clusters; } return clusters; } /** * Filter the items per group. * @private */ _filterData() { // filter per group const groups = {}; this.groups = groups; // split the items per group for (const item of Object.values(this.items)) { // put the item in the correct group const groupName = item.parent ? item.parent.groupId : ''; let group = groups[groupName]; if (!group) { group = []; groups[groupName] = group; } group.push(item); // calculate the center of the item if (item.data.start) { if (item.data.end) { // range item.center = (item.data.start.valueOf() + item.data.end.valueOf()) / 2; } else { // box, dot item.center = item.data.start.valueOf(); } } } // sort the items per group for (let currentGroupName in groups) { if (groups.hasOwnProperty(currentGroupName)) { groups[currentGroupName].sort((a, b) => a.center - b.center); } } this.dataChanged = false; } /** * Create new cluster or return existing * @private * @param {array} clusterItems * @param {object} group * @param {array} oldClusters * @param {object} options * @returns {object} cluster */ _getClusterForItems(clusterItems, group, oldClusters, options) { const oldClustersLookup = (oldClusters || []).map(cluster => ({ cluster, itemsIds: new Set(cluster.data.uiItems.map(item => item.id)) })); let cluster; if (oldClustersLookup.length) { for (let oldClusterData of oldClustersLookup) { if (oldClusterData.itemsIds.size === clusterItems.length && clusterItems.every(clusterItem => oldClusterData.itemsIds.has(clusterItem.id))) { cluster = oldClusterData.cluster; break; } } } if (cluster) { cluster.setUiItems(clusterItems); if (cluster.group !== group) { if (cluster.group) { cluster.group.remove(cluster); } if (group) { group.add(cluster); cluster.group = group; } } return cluster; } let titleTemplate = options.titleTemplate || ''; const conversion = { toScreen: this.itemSet.body.util.toScreen, toTime: this.itemSet.body.util.toTime }; const clusterContent = '<div title="' + title + '">' + clusterItems.length + '</div>'; const title = titleTemplate.replace(/{count}/, clusterItems.length); const clusterOptions = Object.assign({}, options, this.itemSet.options); const data = { 'content': clusterContent, 'title': title, 'group': group, 'uiItems': clusterItems, 'eventEmitter': this.itemSet.body.emitter, 'range': this.itemSet.body.range }; cluster = this.createClusterItem(data, conversion, clusterOptions); if (group) { group.add(cluster); cluster.group = group; } cluster.attach(); return cluster; } /** * Drop cache * @private */ _dropLevelsCache() { this.cache = {}; this.cacheLevel = -1; this.cache[this.cacheLevel] = []; } }