vis-timeline
Version:
Create a fully customizable, interactive timeline with items and ranges.
1,556 lines (1,413 loc) • 638 kB
JavaScript
/**
* vis-timeline and vis-graph2d
* https://visjs.github.io/vis-timeline/
*
* Create a fully customizable, interactive timeline with items and ranges.
*
* @version 8.5.3
* @date 2026-08-05T15:55:32.472Z
*
* @copyright (c) 2011-2017 Almende B.V, http://almende.com
* @copyright (c) 2017-2019 visjs contributors, https://github.com/visjs
*
* @license
* vis.js is dual licensed under both
*
* 1. The Apache 2.0 License
* http://www.apache.org/licenses/LICENSE-2.0
*
* and
*
* 2. The MIT License
* http://opensource.org/licenses/MIT
*
* vis.js may be distributed under either license.
*/
import moment$3 from 'moment';
import * as util from 'vis-util/esnext/esm/vis-util.js';
import { isNumber, isString, getType } from 'vis-util/esnext/esm/vis-util.js';
import { isDataViewLike as isDataViewLike$1, DataSet, createNewDataPipeFrom, DataView } from 'vis-data/esnext/esm/vis-data.js';
import xssFilter from 'xss';
import { v4 } from 'uuid';
import Hammer from '@egjs/hammerjs';
import PropagatingHammer from 'propagating-hammerjs';
import Emitter from 'component-emitter';
import keycharm from 'keycharm';
// DOM utility methods
/**
* this prepares the JSON container for allocating SVG elements
* @param {Object} JSONcontainer
* @private
*/
function prepareElements(JSONcontainer) {
// cleanup the redundant svgElements;
for (var elementType in JSONcontainer) {
if (!Object.prototype.hasOwnProperty.call(JSONcontainer, elementType))
continue;
JSONcontainer[elementType].redundant = JSONcontainer[elementType].used;
JSONcontainer[elementType].used = [];
}
}
/**
* this cleans up all the unused SVG elements. By asking for the parentNode, we only need to supply the JSON container from
* which to remove the redundant elements.
*
* @param {Object} JSONcontainer
* @private
*/
function cleanupElements(JSONcontainer) {
// cleanup the redundant svgElements;
for (var elementType in JSONcontainer) {
if (!Object.prototype.hasOwnProperty.call(JSONcontainer, elementType))
continue;
const elementTypeJsonContainer = JSONcontainer[elementType];
for (var i = 0; i < elementTypeJsonContainer.redundant.length; i++) {
elementTypeJsonContainer.redundant[i].parentNode.removeChild(
elementTypeJsonContainer.redundant[i],
);
}
elementTypeJsonContainer.redundant = [];
}
}
/**
* Ensures that all elements are removed first up so they can be recreated cleanly
* @param {Object} JSONcontainer
*/
function resetElements(JSONcontainer) {
prepareElements(JSONcontainer);
cleanupElements(JSONcontainer);
prepareElements(JSONcontainer);
}
/**
* Allocate or generate an SVG element if needed. Store a reference to it in the JSON container and draw it in the svgContainer
* the JSON container and the SVG container have to be supplied so other svg containers (like the legend) can use this.
*
* @param {string} elementType
* @param {Object} JSONcontainer
* @param {Object} svgContainer
* @returns {Element}
* @private
*/
function getSVGElement(elementType, JSONcontainer, svgContainer) {
var element;
// allocate SVG element, if it doesnt yet exist, create one.
if (Object.prototype.hasOwnProperty.call(JSONcontainer, elementType)) {
// this element has been created before
// check if there is an redundant element
if (JSONcontainer[elementType].redundant.length > 0) {
element = JSONcontainer[elementType].redundant[0];
JSONcontainer[elementType].redundant.shift();
} else {
// create a new element and add it to the SVG
element = document.createElementNS(
"http://www.w3.org/2000/svg",
elementType,
);
svgContainer.appendChild(element);
}
} else {
// create a new element and add it to the SVG, also create a new object in the svgElements to keep track of it.
element = document.createElementNS(
"http://www.w3.org/2000/svg",
elementType,
);
JSONcontainer[elementType] = { used: [], redundant: [] };
svgContainer.appendChild(element);
}
JSONcontainer[elementType].used.push(element);
return element;
}
/**
* Allocate or generate an SVG element if needed. Store a reference to it in the JSON container and draw it in the svgContainer
* the JSON container and the SVG container have to be supplied so other svg containers (like the legend) can use this.
*
* @param {string} elementType
* @param {Object} JSONcontainer
* @param {Element} DOMContainer
* @param {Element} insertBefore
* @returns {*}
*/
function getDOMElement(
elementType,
JSONcontainer,
DOMContainer,
insertBefore,
) {
var element;
// allocate DOM element, if it doesnt yet exist, create one.
if (Object.prototype.hasOwnProperty.call(JSONcontainer, elementType)) {
// this element has been created before
// check if there is an redundant element
if (JSONcontainer[elementType].redundant.length > 0) {
element = JSONcontainer[elementType].redundant[0];
JSONcontainer[elementType].redundant.shift();
} else {
// create a new element and add it to the SVG
element = document.createElement(elementType);
{
DOMContainer.appendChild(element);
}
}
} else {
// create a new element and add it to the SVG, also create a new object in the svgElements to keep track of it.
element = document.createElement(elementType);
JSONcontainer[elementType] = { used: [], redundant: [] };
{
DOMContainer.appendChild(element);
}
}
JSONcontainer[elementType].used.push(element);
return element;
}
/**
* Draw a point object. This is a separate function because it can also be called by the legend.
* The reason the JSONcontainer and the target SVG svgContainer have to be supplied is so the legend can use these functions
* as well.
*
* @param {number} x
* @param {number} y
* @param {Object} groupTemplate: A template containing the necessary information to draw the datapoint e.g., {style: 'circle', size: 5, className: 'className' }
* @param {Object} JSONcontainer
* @param {Object} svgContainer
* @param {Object} labelObj
* @returns {vis.PointItem}
*/
function drawPoint(
x,
y,
groupTemplate,
JSONcontainer,
svgContainer,
labelObj,
) {
var point;
if (groupTemplate.style == "circle") {
point = getSVGElement("circle", JSONcontainer, svgContainer);
point.setAttributeNS(null, "cx", x);
point.setAttributeNS(null, "cy", y);
point.setAttributeNS(null, "r", 0.5 * groupTemplate.size);
} else {
point = getSVGElement("rect", JSONcontainer, svgContainer);
point.setAttributeNS(null, "x", x - 0.5 * groupTemplate.size);
point.setAttributeNS(null, "y", y - 0.5 * groupTemplate.size);
point.setAttributeNS(null, "width", groupTemplate.size);
point.setAttributeNS(null, "height", groupTemplate.size);
}
if (groupTemplate.styles !== undefined) {
point.setAttributeNS(null, "style", groupTemplate.styles);
}
point.setAttributeNS(null, "class", groupTemplate.className + " vis-point");
//handle label
if (labelObj) {
var label = getSVGElement("text", JSONcontainer, svgContainer);
if (labelObj.xOffset) {
x = x + labelObj.xOffset;
}
if (labelObj.yOffset) {
y = y + labelObj.yOffset;
}
if (labelObj.content) {
label.textContent = labelObj.content;
}
if (labelObj.className) {
label.setAttributeNS(null, "class", labelObj.className + " vis-label");
}
label.setAttributeNS(null, "x", x);
label.setAttributeNS(null, "y", y);
}
return point;
}
/**
* draw a bar SVG element centered on the X coordinate
*
* @param {number} x
* @param {number} y
* @param {number} width
* @param {number} height
* @param {string} className
* @param {Object} JSONcontainer
* @param {Object} svgContainer
* @param {string} style
*/
function drawBar(
x,
y,
width,
height,
className,
JSONcontainer,
svgContainer,
style,
) {
if (height != 0) {
if (height < 0) {
height *= -1;
y -= height;
}
var rect = getSVGElement("rect", JSONcontainer, svgContainer);
rect.setAttributeNS(null, "x", x - 0.5 * width);
rect.setAttributeNS(null, "y", y);
rect.setAttributeNS(null, "width", width);
rect.setAttributeNS(null, "height", height);
rect.setAttributeNS(null, "class", className);
if (style) {
rect.setAttributeNS(null, "style", style);
}
}
}
/**
* get default language
* @returns {string}
*/
function getNavigatorLanguage() {
try {
if (!navigator) return "en";
if (navigator.languages && navigator.languages.length) {
return navigator.languages;
} else {
return (
navigator.userLanguage ||
navigator.language ||
navigator.browserLanguage ||
"en"
);
}
} catch {
return "en";
}
}
// utility functions
/**
* Test if an object implements the DataView interface from vis-data.
* Uses the idProp property instead of expecting a hardcoded id field "id".
* @param {Object} obj The object to test.
* @returns {boolean} True if the object implements vis-data DataView interface otherwise false.
*/
function isDataViewLike(obj) {
if (!obj) {
return false;
}
let idProp = obj.idProp ?? obj._idProp;
if (!idProp) {
return false;
}
return isDataViewLike$1(idProp, obj);
}
// parse ASP.Net Date pattern,
// for example '/Date(1198908717056)/' or '/Date(1198908717056-0700)/'
// code from http://momentjs.com/
const ASPDateRegex = /^\/?Date\((-?\d+)/i;
const NumericRegex = /^\d+$/;
/**
* Convert an object into another type
*
* @param {Object} object - Value of unknown type.
* @param {string} type - Name of the desired type.
*
* @returns {Object} Object in the desired type.
* @throws Error
*/
function convert(object, type) {
let match;
if (object === undefined) {
return undefined;
}
if (object === null) {
return null;
}
if (!type) {
return object;
}
if (!(typeof type === "string") && !(type instanceof String)) {
throw new Error("Type must be a string");
}
//noinspection FallthroughInSwitchStatementJS
switch (type) {
case "boolean":
case "Boolean":
return Boolean(object);
case "number":
case "Number":
if (isString(object) && !isNaN(Date.parse(object))) {
return moment$3(object).valueOf();
} else {
// @TODO: I don't think that Number and String constructors are a good idea.
// This could also fail if the object doesn't have valueOf method or if it's redefined.
// For example: Object.create(null) or { valueOf: 7 }.
return Number(object.valueOf());
}
case "string":
case "String":
return String(object);
case "Date":
try {
return convert(object, "Moment").toDate();
} catch (e) {
if (e instanceof TypeError) {
throw new TypeError(
"Cannot convert object of type " +
getType(object) +
" to type " +
type,
{ cause: e },
);
} else {
throw e;
}
}
case "Moment":
if (isNumber(object)) {
return moment$3(object);
}
if (object instanceof Date) {
return moment$3(object.valueOf());
} else if (moment$3.isMoment(object)) {
return moment$3(object);
}
if (isString(object)) {
match = ASPDateRegex.exec(object);
if (match) {
// object is an ASP date
return moment$3(Number(match[1])); // parse number
}
match = NumericRegex.exec(object);
if (match) {
return moment$3(Number(object));
}
return moment$3(object); // parse string
} else {
throw new TypeError(
"Cannot convert object of type " +
getType(object) +
" to type " +
type,
);
}
case "ISODate":
if (isNumber(object)) {
return new Date(object);
} else if (object instanceof Date) {
return object.toISOString();
} else if (moment$3.isMoment(object)) {
return object.toDate().toISOString();
} else if (isString(object)) {
match = ASPDateRegex.exec(object);
if (match) {
// object is an ASP date
return new Date(Number(match[1])).toISOString(); // parse number
} else {
return moment$3(object).format(); // ISO 8601
}
} else {
throw new Error(
"Cannot convert object of type " +
getType(object) +
" to type ISODate",
);
}
case "ASPDate":
if (isNumber(object)) {
return "/Date(" + object + ")/";
} else if (object instanceof Date || moment$3.isMoment(object)) {
return "/Date(" + object.valueOf() + ")/";
} else if (isString(object)) {
match = ASPDateRegex.exec(object);
let value;
if (match) {
// object is an ASP date
value = new Date(Number(match[1])).valueOf(); // parse number
} else {
value = new Date(object).valueOf(); // parse string
}
return "/Date(" + value + ")/";
} else {
throw new Error(
"Cannot convert object of type " +
getType(object) +
" to type ASPDate",
);
}
default:
throw new Error(`Unknown type ${type}`);
}
}
/**
* Create a Data Set like wrapper to seamlessly coerce data types.
*
* @param {Object} rawDS - The Data Set with raw uncoerced data.
* @param {Object} type - A record assigning a data type to property name.
* @param {string} type.start - Data type name of property 'start'. Default: Date.
* @param {string} type.end - Data type name of property 'end'. Default: Date.
*
* @remarks
* The write operations (`add`, `remove`, `update` and `updateOnly`) write into
* the raw (uncoerced) data set. These values are then picked up by a pipe
* which coerces the values using the [[convert]] function and feeds them into
* the coerced data set. When querying (`forEach`, `get`, `getIds`, `off` and
* `on`) the values are then fetched from the coerced data set and already have
* the required data types. The values are coerced only once when inserted and
* then the same value is returned each time until it is updated or deleted.
*
* For example: `typeCoercedDataSet.add({ id: 7, start: "2020-01-21" })` would
* result in `typeCoercedDataSet.get(7)` returning `{ id: 7, start: moment(new
* Date("2020-01-21")).toDate() }`.
*
* Use the dispose method prior to throwing a reference to this away. Otherwise
* the pipe connecting the two Data Sets will keep the unaccessible coerced
* Data Set alive and updated as long as the raw Data Set exists.
*
* @returns {Object} A Data Set like object that saves data into the raw Data Set and
* retrieves them from the coerced Data Set.
*/
function typeCoerceDataSet(
rawDS,
type = { start: "Date", end: "Date" },
) {
const idProp = rawDS._idProp;
const coercedDS = new DataSet({ fieldId: idProp });
const pipe = createNewDataPipeFrom(rawDS)
.map((item) =>
Object.keys(item).reduce((acc, key) => {
acc[key] = convert(item[key], type[key]);
return acc;
}, {}),
)
.to(coercedDS);
pipe.all().start();
return {
// Write only.
add: (...args) => rawDS.getDataSet().add(...args),
remove: (...args) => rawDS.getDataSet().remove(...args),
update: (...args) => rawDS.getDataSet().update(...args),
updateOnly: (...args) => rawDS.getDataSet().updateOnly(...args),
clear: (...args) => rawDS.getDataSet().clear(...args),
// Read only.
forEach: coercedDS.forEach.bind(coercedDS),
get: coercedDS.get.bind(coercedDS),
getIds: coercedDS.getIds.bind(coercedDS),
off: coercedDS.off.bind(coercedDS),
on: coercedDS.on.bind(coercedDS),
get length() {
return coercedDS.length;
},
// Non standard.
idProp,
type,
rawDS,
coercedDS,
dispose: () => pipe.stop(),
};
}
// Configure XSS protection
const setupXSSCleaner = (options) => {
const customXSS = new xssFilter.FilterXSS(options);
return (input) => {
if (typeof input === "string") {
return customXSS.process(input);
}
return input; // Leave other types unchanged
};
};
const setupNoOpCleaner = (string) => string;
// when nothing else is configured: filter XSS with the lib's default options
let configuredXSSProtection = setupXSSCleaner();
const setupXSSProtection = (options) => {
// No options? Do nothing.
if (!options) {
return;
}
// Disable XSS protection completely on request
if (options.disabled === true) {
configuredXSSProtection = setupNoOpCleaner;
console.warn(
"You disabled XSS protection for vis-Timeline. I sure hope you know what you're doing!",
);
} else {
// Configure XSS protection with some custom options.
// For a list of valid options check the lib's documentation:
// https://github.com/leizongmin/js-xss#custom-filter-rules
if (options.filterOptions) {
configuredXSSProtection = setupXSSCleaner(options.filterOptions);
}
}
};
const availableUtils = {
...util,
convert,
setupXSSProtection,
};
Object.defineProperty(availableUtils, "xss", {
get: function () {
return configuredXSSProtection;
},
});
// Utility functions for ordering and stacking of items
const EPSILON = 0.001; // used when checking collisions, to prevent round-off errors
/**
* Order items by their start data
* @param {Item[]} items
*/
function orderByStart(items) {
items.sort((a, b) => a.data.start - b.data.start);
}
/**
* Order items by their end date. If they have no end date, their start date
* is used.
* @param {Item[]} items
*/
function orderByEnd(items) {
items.sort((a, b) => {
const aTime = "end" in a.data ? a.data.end : a.data.start;
const bTime = "end" in b.data ? b.data.end : b.data.start;
return aTime - bTime;
});
}
/**
* Adjust vertical positions of the items such that they don't overlap each
* other.
* @param {Item[]} items
* All visible items
* @param {{item: {horizontal: number, vertical: number}, axis: number}} margin
* Margins between items and between items and the axis.
* @param {boolean} [force=false]
* If true, all items will be repositioned. If false (default), only
* items having a top===null will be re-stacked
* @param {function} shouldBailItemsRedrawFunction
* bailing function
* @return {boolean} shouldBail
*/
function stack(items, margin, force, shouldBailItemsRedrawFunction) {
const stackingResult = performStacking(
items,
margin.item,
false,
(item) => item.stack && (force || item.top === null),
(item) => item.stack,
() => margin.axis,
shouldBailItemsRedrawFunction,
);
// If shouldBail function returned true during stacking calculation
return stackingResult === null;
}
/**
* Adjust vertical positions of the items within a single subgroup such that they
* don't overlap each other.
* @param {Item[]} items
* All items withina subgroup
* @param {{item: {horizontal: number, vertical: number}, axis: number}} margin
* Margins between items and between items and the axis.
* @param {subgroup} subgroup
* The subgroup that is being stacked
*/
function substack(items, margin, subgroup) {
const subgroupHeight = performStacking(
items,
margin.item,
false,
(item) => item.stack,
() => true,
(item) => item.baseTop,
);
subgroup.height = subgroupHeight - subgroup.top + 0.5 * margin.item.vertical;
}
/**
* Adjust vertical positions of the items without stacking them
* @param {Item[]} items
* All visible items
* @param {{item: {horizontal: number, vertical: number}, axis: number}} margin
* Margins between items and between items and the axis.
* @param {subgroups[]} subgroups
* All subgroups
* @param {boolean} isStackSubgroups
*/
function nostack(items, margin, subgroups, isStackSubgroups) {
for (let i = 0; i < items.length; i++) {
if (items[i].data.subgroup == undefined) {
items[i].top = margin.item.vertical;
continue;
}
if (items[i].data.subgroup === undefined || !isStackSubgroups) continue;
let newTop = 0;
for (const subgroup in subgroups) {
if (
!Object.prototype.hasOwnProperty.call(subgroups, subgroup) ||
subgroups[subgroup].visible !== true ||
subgroups[subgroup].index >= subgroups[items[i].data.subgroup].index
)
continue;
newTop += subgroups[subgroup].height;
subgroups[items[i].data.subgroup].top = newTop;
}
items[i].top = newTop + 0.5 * margin.item.vertical;
}
if (!isStackSubgroups) stackSubgroups(items, margin, subgroups);
}
/**
* Adjust vertical positions of the subgroups such that they don't overlap each
* other.
* @param {Array.<timeline.Item>} items
* @param {{item: {horizontal: number, vertical: number}, axis: number}} margin Margins between items and between items and the axis.
* @param {subgroups[]} subgroups
* All subgroups
*/
function stackSubgroups(items, margin, subgroups) {
performStacking(
Object.values(subgroups).toSorted((a, b) => {
if (a.index > b.index) return 1;
if (a.index < b.index) return -1;
return 0;
}),
{
vertical: 0,
},
true,
() => true,
() => true,
() => 0,
);
for (let i = 0; i < items.length; i++) {
if (items[i].data.subgroup !== undefined) {
items[i].top =
subgroups[items[i].data.subgroup].top + 0.5 * margin.item.vertical;
}
}
}
/**
* Adjust vertical positions of the subgroups such that they don't overlap each
* other, then stacks the contents of each subgroup individually.
* @param {Item[]} subgroupItems
* All the items in a subgroup
* @param {{item: {horizontal: number, vertical: number}, axis: number}} margin
* Margins between items and between items and the axis.
* @param {subgroups[]} subgroups
* All subgroups
*/
function stackSubgroupsWithInnerStack(subgroupItems, margin, subgroups) {
let doSubStack = false;
// Run subgroups in their order (if any)
const subgroupOrder = [];
for (let subgroup in subgroups) {
if (Object.prototype.hasOwnProperty.call(subgroups[subgroup], "index")) {
subgroupOrder[subgroups[subgroup].index] = subgroup;
} else {
subgroupOrder.push(subgroup);
}
}
for (let j = 0; j < subgroupOrder.length; j++) {
let subgroup = subgroupOrder[j];
if (!Object.prototype.hasOwnProperty.call(subgroups, subgroup)) continue;
doSubStack = doSubStack || subgroups[subgroup].stack;
subgroups[subgroup].top = 0;
for (const otherSubgroup in subgroups) {
if (
subgroups[otherSubgroup].visible &&
subgroups[subgroup].index > subgroups[otherSubgroup].index
) {
subgroups[subgroup].top += subgroups[otherSubgroup].height;
}
}
const items = subgroupItems[subgroup];
for (let i = 0; i < items.length; i++) {
if (items[i].data.subgroup === undefined) continue;
items[i].top =
subgroups[items[i].data.subgroup].top + 0.5 * margin.item.vertical;
if (subgroups[subgroup].stack) items[i].baseTop = items[i].top;
}
if (doSubStack && subgroups[subgroup].stack)
substack(subgroupItems[subgroup], margin, subgroups[subgroup]);
}
}
/**
* Reusable stacking function
*
* @param {Item[]} items
* An array of items to consider during stacking.
* @param {{horizontal: number, vertical: number}} margins
* Margins to be used for collision checking and placement of items.
* @param {boolean} compareTimes
* By default, horizontal collision is checked based on the spatial position of the items (left/right and width).
* If this argument is true, horizontal collision will instead be checked based on the start/end times of each item.
* Vertical collision is always checked spatially.
* @param {function(Item): number | null} shouldStack
* A callback function which is called before we start to process an item. The return value indicates whether the item will be processed.
* @param {function(Item): boolean} shouldOthersStack
* A callback function which indicates whether other items should consider this item when being stacked.
* @param {function(Item): number} getInitialHeight
* A callback function which determines the height items are initially placed at
* @param {function(): boolean} shouldBail
* A callback function which should indicate if the stacking process should be aborted.
*
* @returns {null|number}
* if shouldBail was triggered, returns null
* otherwise, returns the maximum height
*/
function performStacking(
items,
margins,
compareTimes,
shouldStack,
shouldOthersStack,
getInitialHeight,
shouldBail,
) {
// Time-based horizontal comparison
let getItemStart = (item) => item.start;
let getItemEnd = (item) => item.end;
if (!compareTimes) {
// Spatial horizontal comparisons
const rtl = !!(items[0] && items[0].options.rtl);
if (rtl) {
getItemStart = (item) => item.right;
} else {
getItemStart = (item) => item.left;
}
getItemEnd = (item) => getItemStart(item) + item.width + margins.horizontal;
}
const itemsToPosition = [];
const itemsAlreadyPositioned = []; // It's vital that this array is kept sorted based on the start of each item
// If the order we needed to place items was based purely on the start of each item, we could calculate stacking very efficiently.
// Unfortunately for us, this is not guaranteed. But the order is often based on the start of items at least to some degree, and
// we can use this to make some optimisations. While items are proceeding in order of start, we can keep moving our search indexes
// forwards. Then if we encounter an item that's out of order, we reset our indexes and search from the beginning of the array again.
let previousStart = null;
let insertionIndex = 0;
// First let's handle any immoveable items
for (const item of items) {
if (shouldStack(item)) {
itemsToPosition.push(item);
} else {
if (shouldOthersStack(item)) {
const itemStart = getItemStart(item);
// We need to put immoveable items into itemsAlreadyPositioned and ensure that this array is sorted.
// We could simply insert them, and then use JavaScript's sort function to sort them afterwards.
// This would achieve an average complexity of O(n log n).
//
// Instead, I'm gambling that the start of each item will usually be the same or later than the
// start of the previous item. While this holds (best case), we can insert items in O(n).
// In the worst case (where each item starts before the previous item) this grows to O(n^2).
//
// I am making the assumption that for most datasets, the "order" function will have relatively low cardinality,
// and therefore this tradeoff should be easily worth it.
if (previousStart !== null && itemStart < previousStart - EPSILON) {
insertionIndex = 0;
}
previousStart = itemStart;
insertionIndex = findIndexFrom(
itemsAlreadyPositioned,
(i) => getItemStart(i) - EPSILON > itemStart,
insertionIndex,
);
itemsAlreadyPositioned.splice(insertionIndex, 0, item);
insertionIndex++;
}
}
}
// Now we can loop through each item (in order) and find a position for them
previousStart = null;
let previousEnd = null;
insertionIndex = 0;
let horizontalOverlapStartIndex = 0;
let horizontalOverlapEndIndex = 0;
let maxHeight = 0;
while (itemsToPosition.length > 0) {
const item = itemsToPosition.shift();
item.top = getInitialHeight(item);
const itemStart = getItemStart(item);
const itemEnd = getItemEnd(item);
if (previousStart !== null && itemStart < previousStart - EPSILON) {
horizontalOverlapStartIndex = 0;
horizontalOverlapEndIndex = 0;
insertionIndex = 0;
previousEnd = null;
}
if (previousStart === null || itemStart > previousStart + EPSILON) {
// Take advantage of the sorted itemsAlreadyPositioned array to narrow down the search
horizontalOverlapStartIndex = findIndexFrom(
itemsAlreadyPositioned,
(i) => itemStart < getItemEnd(i) - EPSILON,
horizontalOverlapStartIndex,
);
}
previousStart = itemStart;
// Since items aren't sorted by end time, it might increase or decrease from one item to the next. In order to keep an efficient search area, we will seek forwards/backwards accordingly.
if (previousEnd === null || previousEnd < itemEnd - EPSILON) {
horizontalOverlapEndIndex = findIndexFrom(
itemsAlreadyPositioned,
(i) => itemEnd < getItemStart(i) - EPSILON,
Math.max(horizontalOverlapStartIndex, horizontalOverlapEndIndex),
);
}
if (previousEnd !== null && previousEnd - EPSILON > itemEnd) {
horizontalOverlapEndIndex =
findLastIndexBetween(
itemsAlreadyPositioned,
(i) => itemEnd + EPSILON >= getItemStart(i),
horizontalOverlapStartIndex,
horizontalOverlapEndIndex,
) + 1;
}
previousEnd = itemEnd;
// Sort by vertical position so we don't have to reconsider past items if we move an item
const horizontallyCollidingItems = filterBetween(
itemsAlreadyPositioned,
(i) => itemStart < getItemEnd(i) - EPSILON,
horizontalOverlapStartIndex,
horizontalOverlapEndIndex,
).toSorted((a, b) => a.top - b.top);
// Keep moving the item down until it stops colliding with any other items
for (let i2 = 0; i2 < horizontallyCollidingItems.length; i2++) {
const otherItem = horizontallyCollidingItems[i2];
if (checkVerticalSpatialCollision(item, otherItem, margins)) {
item.top = otherItem.top + otherItem.height + margins.vertical;
}
}
if (shouldOthersStack(item)) {
// Insert the item into itemsAlreadyPositioned, ensuring itemsAlreadyPositioned remains sorted.
// In the best case, we can insert an item in constant time O(1). In the worst case, we insert an item in linear time O(n).
// In both cases, this is better than doing a naive insert and then sort, which would cost on average O(n log n).
insertionIndex = findIndexFrom(
itemsAlreadyPositioned,
(i) => getItemStart(i) - EPSILON > itemStart,
insertionIndex,
);
itemsAlreadyPositioned.splice(insertionIndex, 0, item);
if (insertionIndex < horizontalOverlapStartIndex) {
horizontalOverlapStartIndex++;
}
if (insertionIndex <= horizontalOverlapEndIndex) {
horizontalOverlapEndIndex++;
}
insertionIndex++;
}
// Keep track of the tallest item we've seen before
const currentHeight = item.top + item.height;
if (currentHeight > maxHeight) {
maxHeight = currentHeight;
}
if (shouldBail && shouldBail()) {
return null;
}
}
return maxHeight;
}
/**
* Test if the two provided items collide
* The items must have parameters left, width, top, and height.
* @param {Item} a The first item
* @param {Item} b The second item
* @param {{vertical: number}} margin
* An object containing a horizontal and vertical
* minimum required margin.
* @return {boolean} true if a and b collide, else false
*/
function checkVerticalSpatialCollision(a, b, margin) {
return (
a.top - margin.vertical + EPSILON < b.top + b.height &&
a.top + a.height + margin.vertical - EPSILON > b.top
);
}
/**
* Find index of first item to meet predicate after a certain index.
* If no such item is found, returns the length of the array.
*
* @param {any[]} arr The array
* @param {function(item): boolean} predicate A function that should return true when a suitable item is found
* @param {number|undefined} startIndex The index to start search from (inclusive). Optional, if not provided will search from the beginning of the array.
*
* @return {number}
*/
function findIndexFrom(arr, predicate, startIndex) {
if (!startIndex) {
startIndex = 0;
}
for (let i = startIndex; i < arr.length; i++) {
if (predicate(arr[i])) {
return i;
}
}
return arr.length;
}
/**
* Find index of last item to meet predicate within a given range.
* If no such item is found, returns the index prior to the start of the range.
*
* @param {any[]} arr The array
* @param {function(item): boolean} predicate A function that should return true when a suitable item is found
* @param {number|undefined} startIndex The earliest index to search to (inclusive). Optional, if not provided will continue until the start of the array.
* @param {number|undefined} endIndex The end of the search range (exclusive). The search will begin on the index prior to this value. Optional, defaults to the end of array.
*
* @return {number}
*/
function findLastIndexBetween(arr, predicate, startIndex, endIndex) {
if (!startIndex) {
startIndex = 0;
}
if (!endIndex) {
endIndex = arr.length;
}
for (let i = endIndex - 1; i >= startIndex; i--) {
if (predicate(arr[i])) {
return i;
}
}
return startIndex - 1;
}
/**
* Takes an array and returns an array containing only items which meet a predicate within a given range.
*
* @param {any[]} arr The array
* @param {function(item): boolean} predicate A function that should return true for items which should be included within the result
* @param {number|undefined} startIndex The earliest index to include (inclusive). Optional, if not provided will continue until the start of the array.
* @param {number|undefined} endIndex The end of the range to filter (exclusive). Optional, defaults to the end of array.
*
* @return {number}
*/
function filterBetween(arr, predicate, startIndex, endIndex) {
if (!startIndex) {
startIndex = 0;
}
if (endIndex) {
endIndex = Math.min(endIndex, arr.length);
} else {
endIndex = arr.length;
}
const result = [];
for (let i = startIndex; i < endIndex; i++) {
if (predicate(arr[i])) {
result.push(arr[i]);
}
}
return result;
}
var stack$1 = /*#__PURE__*/Object.freeze({
__proto__: null,
nostack: nostack,
orderByEnd: orderByEnd,
orderByStart: orderByStart,
stack: stack,
stackSubgroups: stackSubgroups,
stackSubgroupsWithInnerStack: stackSubgroupsWithInnerStack,
substack: substack
});
const BACKGROUND$1 = "__background__"; // reserved group id for background items without group
const ReservedGroupIds$1 = {
BACKGROUND: BACKGROUND$1,
};
/**
* @constructor Group
*/
class Group {
/**
* @param {number | string} groupId
* @param {Object} data
* @param {ItemSet} itemSet
* @constructor Group
*/
constructor(groupId, data, itemSet) {
this.groupId = groupId;
this.subgroups = {};
this.subgroupStack = {};
this.subgroupStackAll = false;
this.subgroupVisibility = {};
this.doInnerStack = false;
this.shouldBailStackItems = false;
this.subgroupIndex = 0;
this.subgroupOrderer = data && data.subgroupOrder;
this.itemSet = itemSet;
this.isVisible = null;
this.height = 0;
this.stackDirty = true; // if true, items will be restacked on next redraw
// This is a stack of functions (`() => void`) that will be executed before
// the instance is disposed off (method `dispose`). Anything that needs to
// be manually disposed off before garbage collection happens (or so that
// garbage collection can happen) should be added to this stack.
this._disposeCallbacks = [];
if (data && data.nestedGroups) {
this.nestedGroups = data.nestedGroups;
if (data.showNested == false) {
this.showNested = false;
} else {
this.showNested = true;
}
}
if (data && data.subgroupStack) {
if (typeof data.subgroupStack === "boolean") {
this.doInnerStack = data.subgroupStack;
this.subgroupStackAll = data.subgroupStack;
} else {
// We might be doing stacking on specific sub groups, but only
// if at least one is set to do stacking
for (const key in data.subgroupStack) {
if (!Object.prototype.hasOwnProperty.call(data.subgroupStack, key))
continue;
this.subgroupStack[key] = data.subgroupStack[key];
this.doInnerStack = this.doInnerStack || data.subgroupStack[key];
}
}
}
if (data && data.heightMode) {
this.heightMode = data.heightMode;
} else {
this.heightMode = itemSet.options.groupHeightMode;
}
this.nestedInGroup = null;
this.dom = {};
this.props = {
label: {
width: 0,
height: 0,
},
};
this.className = null;
this.items = {}; // items filtered by groupId of this group
this.visibleItems = []; // items currently visible in window
this.itemsInRange = []; // items currently in range
this.orderedItems = {
byStart: [],
byEnd: [],
};
this.checkRangedItems = false; // needed to refresh the ranged items if the window is programatically changed with NO overlap.
const handleCheckRangedItems = () => {
this.checkRangedItems = true;
};
this.itemSet.body.emitter.on("checkRangedItems", handleCheckRangedItems);
this._disposeCallbacks.push(() => {
this.itemSet.body.emitter.off("checkRangedItems", handleCheckRangedItems);
});
this._create();
this.setData(data);
}
/**
* Create DOM elements for the group
* @private
*/
_create() {
const label = document.createElement("div");
if (this.itemSet.options.groupEditable.order) {
label.className = "vis-label draggable";
} else {
label.className = "vis-label";
}
this.dom.label = label;
const inner = document.createElement("div");
inner.className = "vis-inner";
label.appendChild(inner);
this.dom.inner = inner;
const foreground = document.createElement("div");
foreground.className = "vis-group";
foreground["vis-group"] = this;
this.dom.foreground = foreground;
this.dom.background = document.createElement("div");
this.dom.background.className = "vis-group";
this.dom.axis = document.createElement("div");
this.dom.axis.className = "vis-group";
// create a hidden marker to detect when the Timelines container is attached
// to the DOM, or the style of a parent of the Timeline is changed from
// display:none is changed to visible.
this.dom.marker = document.createElement("div");
this.dom.marker.style.visibility = "hidden";
this.dom.marker.style.position = "absolute";
this.dom.marker.innerHTML = "";
this.dom.background.appendChild(this.dom.marker);
}
/**
* Set the group data for this group
* @param {Object} data Group data, can contain properties content and className
*/
setData(data) {
if (this.itemSet.groupTouchParams.isDragging) return;
// update contents
let content;
let templateFunction;
if (data && data.subgroupVisibility) {
for (const key in data.subgroupVisibility) {
if (!Object.prototype.hasOwnProperty.call(data.subgroupVisibility, key))
continue;
this.subgroupVisibility[key] = data.subgroupVisibility[key];
}
}
if (this.itemSet.options && this.itemSet.options.groupTemplate) {
templateFunction = this.itemSet.options.groupTemplate.bind(this);
content = templateFunction(data, this.dom.inner);
} else {
content = data && data.content;
}
if (content instanceof Element) {
while (this.dom.inner.firstChild) {
this.dom.inner.removeChild(this.dom.inner.firstChild);
}
this.dom.inner.appendChild(content);
} else if (content instanceof Object && content.isReactComponent) ; else if (content instanceof Object) {
templateFunction(data, this.dom.inner);
} else if (content !== undefined && content !== null) {
this.dom.inner.innerHTML = availableUtils.xss(content);
} else {
this.dom.inner.innerHTML = availableUtils.xss(this.groupId || ""); // groupId can be null
}
// update title
this.dom.label.title = (data && data.title) || "";
if (!this.dom.inner.firstChild) {
availableUtils.addClassName(this.dom.inner, "vis-hidden");
} else {
availableUtils.removeClassName(this.dom.inner, "vis-hidden");
}
if (data && data.nestedGroups) {
if (!this.nestedGroups || this.nestedGroups != data.nestedGroups) {
this.nestedGroups = data.nestedGroups;
}
if (data.showNested !== undefined || this.showNested === undefined) {
if (data.showNested == false) {
this.showNested = false;
} else {
this.showNested = true;
}
}
availableUtils.addClassName(this.dom.label, "vis-nesting-group");
if (this.showNested) {
availableUtils.removeClassName(this.dom.label, "collapsed");
availableUtils.addClassName(this.dom.label, "expanded");
} else {
availableUtils.removeClassName(this.dom.label, "expanded");
availableUtils.addClassName(this.dom.label, "collapsed");
}
} else if (this.nestedGroups) {
this.nestedGroups = null;
availableUtils.removeClassName(this.dom.label, "collapsed");
availableUtils.removeClassName(this.dom.label, "expanded");
availableUtils.removeClassName(this.dom.label, "vis-nesting-group");
}
if (data && (data.treeLevel || data.nestedInGroup)) {
availableUtils.addClassName(this.dom.label, "vis-nested-group");
if (data.treeLevel) {
availableUtils.addClassName(this.dom.label, "vis-group-level-" + data.treeLevel);
} else {
// Nesting level is unknown, but we're sure it's at least 1
availableUtils.addClassName(this.dom.label, "vis-group-level-unknown-but-gte1");
}
} else {
availableUtils.addClassName(this.dom.label, "vis-group-level-0");
}
// update className
const className = (data && data.className) || null;
if (className != this.className) {
if (this.className) {
availableUtils.removeClassName(this.dom.label, this.className);
availableUtils.removeClassName(this.dom.foreground, this.className);
availableUtils.removeClassName(this.dom.background, this.className);
availableUtils.removeClassName(this.dom.axis, this.className);
}
availableUtils.addClassName(this.dom.label, className);
availableUtils.addClassName(this.dom.foreground, className);
availableUtils.addClassName(this.dom.background, className);
availableUtils.addClassName(this.dom.axis, className);
this.className = className;
}
// update style
if (this.style) {
availableUtils.removeCssText(this.dom.label, this.style);
this.style = null;
}
if (data && data.style) {
availableUtils.addCssText(this.dom.label, data.style);
this.style = data.style;
}
}
/**
* Get the width of the group label
* @return {number} width
*/
getLabelWidth() {
return this.props.label.width;
}
/**
* check if group has had an initial height hange
* @returns {boolean}
*/
_didMarkerHeightChange() {
const markerHeight = this.dom.marker.clientHeight;
if (markerHeight != this.lastMarkerHeight) {
this.lastMarkerHeight = markerHeight;
const redrawQueue = {};
let redrawQueueLength = 0;
availableUtils.forEach(this.items, (item, key) => {
item.dirty = true;
if (item.displayed) {
const returnQueue = true;
redrawQueue[key] = item.redraw(returnQueue);
redrawQueueLength = redrawQueue[key].length;
}
});
const needRedraw = redrawQueueLength > 0;
if (needRedraw) {
// redraw all regular items
for (let i = 0; i < redrawQueueLength; i++) {
availableUtils.forEach(redrawQueue, (fns) => {
fns[i]();
});
}
}
return true;
} else {
return false;
}
}
/**
* calculate group dimentions and position
* @param {number} pixels
*/
_calculateGroupSizeAndPosition() {
const { offsetTop, offsetLeft, offsetWidth } = this.dom.foreground;
this.top = offsetTop;
this.right = offsetLeft;
this.width = offsetWidth;
}
/**
* checks if should bail redraw of items
* @returns {boolean} should bail
*/
_shouldBailItemsRedraw() {
const me = this;
const timeoutOptions = this.itemSet.options.onTimeout;
const bailOptions = {
relativeBailingTime: this.itemSet.itemsSettingTime,
bailTimeMs: timeoutOptions && timeoutOptions.timeoutMs,
userBailFunction: timeoutOptions && timeoutOptions.callback,
shouldBailStackItems: this.shouldBailStackItems,
};
let bail = null;
if (!this.itemSet.initialDrawDone) {
if (bailOptions.shouldBailStackItems) {
return true;
}
if (
Math.abs(Date.now() - new Date(bailOptions.relativeBailingTime)) >
bailOptions.bailTimeMs
) {
if (
bailOptions.userBailFunction &&
this.itemSet.userContinueNotBail == null
) {
bailOptions.userBailFunction((didUserContinue) => {
me.itemSet.userContinueNotBail = didUserContinue;
bail = !didUserContinue;
});
} else if (me.itemSet.userContinueNotBail == false) {
bail = true;
} else {
bail = false;
}
}
}
return bail;
}
/**
* redraws items
* @param {boolean} forceRestack
* @param {boolean} lastIsVisible
* @param {number} margin
* @param {object} range
* @private
*/
_redrawItems(forceRestack, lastIsVisible, margin, range) {
const restack =
forceRestack || this.stackDirty || (this.isVisible && !lastIsVisible);
// if restacking, reposition visible items vertically
if (restack) {
const orderedItems = {
byEnd: this.orderedItems.byEnd.filter((item) => !item.isCluster),
byStart: this.orderedItems.byStart.filter((item) => !item.isCluster),
};
const orderedClusters = {
byEnd: [
...new Set(
this.orderedItems.byEnd
.map((item) => item.cluster)
.filter((item) => !!item),
),
],
byStart: [
...new Set(
this.orderedItems.byStart
.map((item) => item.cluster)
.filter((item) => !!item),
),
],
};
/**
* Get all visible items in range
* @return {array} items
*/
const getVisibleItems = () => {
const visibleItems = this._updateItemsInRange(
orderedItems,
this.visibleItems.filter((item) => !item.isCluster),
range,
);
const visibleClusters = this._updateClustersInRange(
orderedClusters,
this.visibleItems.filter((item) => item.isCluster),
range,
);
return [...visibleItems, ...visibleClusters];
};
/**
* Get visible items grouped by subgroup
* @param {function} orderFn An optional function to order items inside the subgroups
* @return {Object}
*/
const getVisibleItemsGroupedBySubgroup = (orderFn) => {
let visibleSubgroupsItems = {};
for (const subgroup in this.subgroups) {
if (!Object.prototype.hasOwnProperty.call(this.subgroups, subgroup))
continue;
const items = this.visibleItems.filter(
(item) => item.data.subgroup === subgroup,
);
visibleSubgroupsItems[subgroup] = orderFn
? items.toSorted((a, b) => orderFn(a.data, b.data))
: items;
}
return visibleSubgroupsItems;
};
if (typeof this.itemSet.options.order === "function") {
// a custom order function
//show all items
const me = this;
if (this.doInnerStack && this.itemSet.options.stackSubgroups) {
// Order the items within each subgroup
const visibleSubgroupsItems = getVisibleItemsGroupedBySubgroup(
this.itemSet.options.order,
);
stackSubgroupsWithInnerStack(
visibleSubgroupsItems,
margin,
this.subgroups,
);
this.visibleItems = getVisibleItems();
this._updateSubGroupHeights(margin);
} else {
this.visibleItems = getVisibleItems();
this._updateSubGroupHeights(margin);
// order all items and force a restacking
// order all items outside clusters and force a restacking
const customOrderedItems = this.visibleItems
.slice()
.filter(
(item) => item.isCluster || (!item.isCluster && !item.cluster),
)
.toSorted((a, b) => {
return me.itemSet.options.order(a.data, b.data);
});
this.shouldBailStackItems = stack(
customOrderedItems,
margin,
true,
this._shouldBailItemsRedraw.bind(this),
);
}
} else {
// no custom order function, lazy stacking
this.visibleItems = getVisibleItems();
this._updateSubGroupHeights(margin);
if (this.itemSet.options.stack) {
if (this.doInnerStack && this.itemSet.options.stackSubgroups) {