UNPKG

vis-timeline

Version:

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

1,556 lines (1,413 loc) 638 kB
/** * 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) {