vis-timeline
Version:
Create a fully customizable, interactive timeline with items and ranges.
1,703 lines (1,548 loc) • 639 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.0
* @date 2025-12-12T13:44:42.806Z
*
* @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/umd/vis-util.js';
import { isNumber, isString, getType } from 'vis-util/esnext/umd/vis-util.js';
import { isDataViewLike as isDataViewLike$1, DataSet, createNewDataPipeFrom, DataView } from 'vis-data/esnext/umd/vis-data.js';
import xssFilter from 'xss';
import { v4 } from 'uuid';
import Emitter from 'component-emitter';
import PropagatingHammer from 'propagating-hammerjs';
import Hammer$1 from '@egjs/hammerjs';
import keycharm from 'keycharm';
// Check if Moment.js is already loaded in the browser window, if so, use this
// instance, else use bundled Moment.js.
const moment$2 =
(typeof window !== "undefined" && window["moment"]) || moment$3;
// 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,
);
} 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;
},
});
/** Prototype for visual components */
class Component {
/**
*/
constructor() {
this.options = null;
this.props = null;
}
/**
* Set options for the component. The new options will be merged into the
* current options.
* @param {Object} options
*/
setOptions(options) {
if (options) {
availableUtils.extend(this.options, options);
}
}
/**
* Repaint the component
* @return {boolean} Returns true if the component is resized
*/
redraw() {
// should be implemented by the component
return false;
}
/**
* Destroy the component. Cleanup DOM and event listeners
*/
destroy() {
// should be implemented by the component
}
/**
* Test whether the component is resized since the last time _isResized() was
* called.
* @return {Boolean} Returns true if the component is resized
* @protected
*/
_isResized() {
const resized =
this.props._previousWidth !== this.props.width ||
this.props._previousHeight !== this.props.height;
this.props._previousWidth = this.props.width;
this.props._previousHeight = this.props.height;
return resized;
}
}
/**
* used in Core to convert the options into a volatile variable
*
* @param {function} moment
* @param {Object} body
* @param {Array | Object} hiddenDates
* @returns {number}
*/
function convertHiddenOptions(moment, body, hiddenDates) {
if (hiddenDates && !Array.isArray(hiddenDates)) {
return convertHiddenOptions(moment, body, [hiddenDates]);
}
body.hiddenDates = [];
if (hiddenDates) {
if (Array.isArray(hiddenDates) == true) {
for (let i = 0; i < hiddenDates.length; i++) {
if (hiddenDates[i].repeat === undefined) {
const dateItem = {};
dateItem.start = moment(hiddenDates[i].start).toDate().valueOf();
dateItem.end = moment(hiddenDates[i].end).toDate().valueOf();
body.hiddenDates.push(dateItem);
}
}
body.hiddenDates.sort((a, b) => a.start - b.start); // sort by start time
}
}
}
/**
* create new entrees for the repeating hidden dates
*
* @param {function} moment
* @param {Object} body
* @param {Array | Object} hiddenDates
* @returns {null}
*/
function updateHiddenDates(moment, body, hiddenDates) {
if (hiddenDates && !Array.isArray(hiddenDates)) {
return updateHiddenDates(moment, body, [hiddenDates]);
}
if (hiddenDates && body.domProps.centerContainer.width !== undefined) {
convertHiddenOptions(moment, body, hiddenDates);
const start = moment(body.range.start);
const end = moment(body.range.end);
const totalRange = body.range.end - body.range.start;
const pixelTime = totalRange / body.domProps.centerContainer.width;
for (let i = 0; i < hiddenDates.length; i++) {
if (hiddenDates[i].repeat !== undefined) {
let startDate = moment(hiddenDates[i].start);
let endDate = moment(hiddenDates[i].end);
if (startDate._d == "Invalid Date") {
throw new Error(
`Supplied start date is not valid: ${hiddenDates[i].start}`,
);
}
if (endDate._d == "Invalid Date") {
throw new Error(
`Supplied end date is not valid: ${hiddenDates[i].end}`,
);
}
const duration = endDate - startDate;
if (duration >= 4 * pixelTime) {
let offset = 0;
let runUntil = end.clone();
switch (hiddenDates[i].repeat) {
case "daily": // case of time
if (startDate.day() != endDate.day()) {
offset = 1;
}
startDate = startDate
.dayOfYear(start.dayOfYear())
.year(start.year())
.subtract(7, "days");
endDate = endDate
.dayOfYear(start.dayOfYear())
.year(start.year())
.subtract(7 - offset, "days");
runUntil.add(1, "weeks");
break;
case "weekly": {
const dayOffset = endDate.diff(startDate, "days");
const day = startDate.day();
// set the start date to the range.start
startDate = startDate
.date(start.date())
.month(start.month())
.year(start.year());
endDate = startDate.clone();
// force
startDate = startDate.day(day).subtract(1, "weeks");
endDate = endDate
.day(day)
.add(dayOffset, "days")
.subtract(1, "weeks");
runUntil.add(1, "weeks");
break;
}
case "monthly":
if (startDate.month() != endDate.month()) {
offset = 1;
}
startDate = startDate
.month(start.month())
.year(start.year())
.subtract(1, "months");
endDate = endDate
.month(start.month())
.year(start.year())
.subtract(1, "months")
.add(offset, "months");
runUntil.add(1, "months");
break;
case "yearly":
if (startDate.year() != endDate.year()) {
offset = 1;
}
startDate = startDate.year(start.year()).subtract(1, "years");
endDate = endDate
.year(start.year())
.subtract(1, "years")
.add(offset, "years");
runUntil.add(1, "years");
break;
default:
console.log(
"Wrong repeat format, allowed are: daily, weekly, monthly, yearly. Given:",
hiddenDates[i].repeat,
);
return;
}
while (startDate < runUntil) {
body.hiddenDates.push({
start: startDate.valueOf(),
end: endDate.valueOf(),
});
switch (hiddenDates[i].repeat) {
case "daily":
startDate = startDate.add(1, "days");
endDate = endDate.add(1, "days");
break;
case "weekly":
startDate = startDate.add(1, "weeks");
endDate = endDate.add(1, "weeks");
break;
case "monthly":
startDate = startDate.add(1, "months");
endDate = endDate.add(1, "months");
break;
case "yearly":
startDate = startDate.add(1, "y");
endDate = endDate.add(1, "y");
break;
default:
console.log(
"Wrong repeat format, allowed are: daily, weekly, monthly, yearly. Given:",
hiddenDates[i].repeat,
);
return;
}
}
body.hiddenDates.push({
start: startDate.valueOf(),
end: endDate.valueOf(),
});
}
}
}
// remove duplicates, merge where possible
removeDuplicates(body);
// ensure the new positions are not on hidden dates
const startHidden = getIsHidden(body.range.start, body.hiddenDates);
const endHidden = getIsHidden(body.range.end, body.hiddenDates);
let rangeStart = body.range.start;
let rangeEnd = body.range.end;
if (startHidden.hidden == true) {
rangeStart =
body.range.startToFront == true
? startHidden.startDate - 1
: startHidden.endDate + 1;
}
if (endHidden.hidden == true) {
rangeEnd =
body.range.endToFront == true
? endHidden.startDate - 1
: endHidden.endDate + 1;
}
if (startHidden.hidden == true || endHidden.hidden == true) {
body.range._applyRange(rangeStart, rangeEnd);
}
}
}
/**
* remove duplicates from the hidden dates list. Duplicates are evil. They mess everything up.
* Scales with N^2
*
* @param {Object} body
*/
function removeDuplicates(body) {
const hiddenDates = body.hiddenDates;
const safeDates = [];
for (var i = 0; i < hiddenDates.length; i++) {
for (let j = 0; j < hiddenDates.length; j++) {
if (
i != j &&
hiddenDates[j].remove != true &&
hiddenDates[i].remove != true
) {
// j inside i
if (
hiddenDates[j].start >= hiddenDates[i].start &&
hiddenDates[j].end <= hiddenDates[i].end
) {
hiddenDates[j].remove = true;
}
// j start inside i
else if (
hiddenDates[j].start >= hiddenDates[i].start &&
hiddenDates[j].start <= hiddenDates[i].end
) {
hiddenDates[i].end = hiddenDates[j].end;
hiddenDates[j].remove = true;
}
// j end inside i
else if (
hiddenDates[j].end >= hiddenDates[i].start &&
hiddenDates[j].end <= hiddenDates[i].end
) {
hiddenDates[i].start = hiddenDates[j].start;
hiddenDates[j].remove = true;
}
}
}
}
for (i = 0; i < hiddenDates.length; i++) {
if (hiddenDates[i].remove !== true) {
safeDates.push(hiddenDates[i]);
}
}
body.hiddenDates = safeDates;
body.hiddenDates.sort((a, b) => a.start - b.start); // sort by start time
}
/**
* Prints dates to console
* @param {array} dates
*/
function printDates(dates) {
for (let i = 0; i < dates.length; i++) {
console.log(
i,
new Date(dates[i].start),
new Date(dates[i].end),
dates[i].start,
dates[i].end,
dates[i].remove,
);
}
}
/**
* Used in TimeStep to avoid the hidden times.
* @param {function} moment
* @param {TimeStep} timeStep
* @param {Date} previousTime
*/
function stepOverHiddenDates(moment, timeStep, previousTime) {
let stepInHidden = false;
const currentValue = timeStep.current.valueOf();
for (let i = 0; i < timeStep.hiddenDates.length; i++) {
const startDate = timeStep.hiddenDates[i].start;
var endDate = timeStep.hiddenDates[i].end;
if (currentValue >= startDate && currentValue < endDate) {
stepInHidden = true;
break;
}
}
if (
stepInHidden == true &&
currentValue < timeStep._end.valueOf() &&
currentValue != previousTime
) {
const prevValue = moment(previousTime);
const newValue = moment(endDate);
//check if the next step should be major
if (prevValue.year() != newValue.year()) {
timeStep.switchedYear = true;
} else if (prevValue.month() != newValue.month()) {
timeStep.switchedMonth = true;
} else if (prevValue.dayOfYear() != newValue.dayOfYear()) {
timeStep.switchedDay = true;
}
timeStep.current = newValue;
}
}
///**
// * Used in TimeStep to avoid the hidden times.
// * @param timeStep
// * @param previousTime
// */
//checkFirstStep = function(timeStep) {
// var stepInHidden = false;
// var currentValue = timeStep.current.valueOf();
// for (var i = 0; i < timeStep.hiddenDates.length; i++) {
// var startDate = timeStep.hiddenDates[i].start;
// var endDate = timeStep.hiddenDates[i].end;
// if (currentValue >= startDate && currentValue < endDate) {
// stepInHidden = true;
// break;
// }
// }
//
// if (stepInHidden == true && currentValue <= timeStep._end.valueOf()) {
// var newValue = moment(endDate);
// timeStep.current = newValue.toDate();
// }
//};
/**
* replaces the Core toScreen methods
*
* @param {timeline.Core} Core
* @param {Date} time
* @param {number} width
* @returns {number}
*/
function toScreen(Core, time, width) {
let conversion;
if (Core.body.hiddenDates.length == 0) {
conversion = Core.range.conversion(width);
return (time.valueOf() - conversion.offset) * conversion.scale;
} else {
const hidden = getIsHidden(time, Core.body.hiddenDates);
if (hidden.hidden == true) {
time = hidden.startDate;
}
const duration = getHiddenDurationBetween(
Core.body.hiddenDates,
Core.range.start,
Core.range.end,
);
if (time < Core.range.start) {
conversion = Core.range.conversion(width, duration);
const hiddenBeforeStart = getHiddenDurationBeforeStart(
Core.body.hiddenDates,
time,
conversion.offset,
);
time = Core.options.moment(time).toDate().valueOf();
time = time + hiddenBeforeStart;
return -(conversion.offset - time.valueOf()) * conversion.scale;
} else if (time > Core.range.end) {
const rangeAfterEnd = { start: Core.range.start, end: time };
time = correctTimeForHidden(
Core.options.moment,
Core.body.hiddenDates,
rangeAfterEnd,
time,
);
conversion = Core.range.conversion(width, duration);
return (time.valueOf() - conversion.offset) * conversion.scale;
} else {
time = correctTimeForHidden(
Core.options.moment,
Core.body.hiddenDates,
Core.range,
time,
);
conversion = Core.range.conversion(width, duration);
return (time.valueOf() - conversion.offset) * conversion.scale;
}
}
}
/**
* Replaces the core toTime methods
*
* @param {timeline.Core} Core
* @param {number} x
* @param {number} width
* @returns {Date}
*/
function toTime(Core, x, width) {
if (Core.body.hiddenDates.length == 0) {
const conversion = Core.range.conversion(width);
return new Date(x / conversion.scale + conversion.offset);
} else {
const hiddenDuration = getHiddenDurationBetween(
Core.body.hiddenDates,
Core.range.start,
Core.range.end,
);
const totalDuration = Core.range.end - Core.range.start - hiddenDuration;
const partialDuration = (totalDuration * x) / width;
const accumulatedHiddenDuration = getAccumulatedHiddenDuration(
Core.body.hiddenDates,
Core.range,
partialDuration,
);
return new Date(
accumulatedHiddenDuration + partialDuration + Core.range.start,
);
}
}
/**
* Support function
*
* @param {Array.<{start: Window.start, end: *}>} hiddenDates
* @param {number} start
* @param {number} end
* @returns {number}
*/
function getHiddenDurationBetween(hiddenDates, start, end) {
let duration = 0;
for (let i = 0; i < hiddenDates.length; i++) {
const startDate = hiddenDates[i].start;
const endDate = hiddenDates[i].end;
// if time after the cutout, and the
if (startDate >= start && endDate < end) {
duration += endDate - startDate;
}
}
return duration;
}
/**
* Support function
*
* @param {Array.<{start: Window.start, end: *}>} hiddenDates
* @param {number} start
* @param {number} end
* @returns {number}
*/
function getHiddenDurationBeforeStart(hiddenDates, start, end) {
let duration = 0;
for (let i = 0; i < hiddenDates.length; i++) {
const startDate = hiddenDates[i].start;
const endDate = hiddenDates[i].end;
if (startDate >= start && endDate <= end) {
duration += endDate - startDate;
}
}
return duration;
}
/**
* Support function
* @param {function} moment
* @param {Array.<{start: Window.start, end: *}>} hiddenDates
* @param {{start: number, end: number}} range
* @param {Date} time
* @returns {number}
*/
function correctTimeForHidden(moment, hiddenDates, range, time) {
time = moment(time).toDate().valueOf();
time -= getHiddenDurationBefore(moment, hiddenDates, range, time);
return time;
}
/**
* Support function
* @param {function} moment
* @param {Array.<{start: Window.start, end: *}>} hiddenDates
* @param {{start: number, end: number}} range
* @param {Date} time
* @returns {number}
*/
function getHiddenDurationBefore(moment, hiddenDates, range, time) {
let timeOffset = 0;
time = moment(time).toDate().valueOf();
for (let i = 0; i < hiddenDates.length; i++) {
const startDate = hiddenDates[i].start;
const endDate = hiddenDates[i].end;
// if time after the cutout, and the
if (startDate >= range.start && endDate < range.end) {
if (time >= endDate) {
timeOffset += endDate - startDate;
}
}
}
return timeOffset;
}
/**
* sum the duration from start to finish, including the hidden duration,
* until the required amount has been reached, return the accumulated hidden duration
* @param {Array.<{start: Window.start, end: *}>} hiddenDates
* @param {{start: number, end: number}} range
* @param {number} [requiredDuration=0]
* @returns {number}
*/
function getAccumulatedHiddenDuration(
hiddenDates,
range,
requiredDuration,
) {
let hiddenDuration = 0;
let duration = 0;
let previousPoint = range.start;
//printDates(hiddenDates)
for (let i = 0; i < hiddenDates.length; i++) {
const startDate = hiddenDates[i].start;
const endDate = hiddenDates[i].end;
// if time after the cutout, and the
if (startDate >= range.start && endDate < range.end) {
duration += startDate - previousPoint;
previousPoint = endDate;
if (duration >= requiredDuration) {
break;
} else {
hiddenDuration += endDate - startDate;
}
}
}
return hiddenDuration;
}
/**
* used to step over to either side of a hidden block. Correction is disabled on tablets, might be set to true
* @param {Array.<{start: Window.start, end: *}>} hiddenDates
* @param {Date} time
* @param {number} direction
* @param {boolean} correctionEnabled
* @returns {Date|number}
*/
function snapAwayFromHidden(
hiddenDates,
time,
direction,
correctionEnabled,
) {
const isHidden = getIsHidden(time, hiddenDates);
if (isHidden.hidden == true) {
if (direction < 0) {
if (correctionEnabled == true) {
return isHidden.startDate - (isHidden.endDate - time) - 1;
} else {
return isHidden.startDate - 1;
}
} else {
if (correctionEnabled == true) {
return isHidden.endDate + (time - isHidden.startDate) + 1;
} else {
return isHidden.endDate + 1;
}
}
} else {
return time;
}
}
/**
* Check if a time is hidden
*
* @param {Date} time
* @param {Array.<{start: Window.start, end: *}>} hiddenDates
* @returns {{hidden: boolean, startDate: Window.start, endDate: *}}
*/
function getIsHidden(time, hiddenDates) {
for (let i = 0; i < hiddenDates.length; i++) {
var startDate = hiddenDates[i].start;
var endDate = hiddenDates[i].end;
if (time >= startDate && time < endDate) {
// if the start is entering a hidden zone
return { hidden: true, startDate, endDate };
}
}
return { hidden: false, startDate, endDate };
}
var DateUtil = /*#__PURE__*/Object.freeze({
__proto__: null,
convertHiddenOptions: convertHiddenOptions,
correctTimeForHidden: correctTimeForHidden,
getAccumulatedHiddenDuration: getAccumulatedHiddenDuration,
getHiddenDurationBefore: getHiddenDurationBefore,
getHiddenDurationBeforeStart: getHiddenDurationBeforeStart,
getHiddenDurationBetween: getHiddenDurationBetween,
getIsHidden: getIsHidden,
printDates: printDates,
removeDuplicates: removeDuplicates,
snapAwayFromHidden: snapAwayFromHidden,
stepOverHiddenDates: stepOverHiddenDates,
toScreen: toScreen,
toTime: toTime,
updateHiddenDates: updateHiddenDates
});
/**
* A Range controls a numeric range with a start and end value.
* The Range adjusts the range based on mouse events or programmatic changes,
* and triggers events when the range is changing or has been changed.
*/
class Range extends Component {
/**
* @param {{dom: Object, domProps: Object, emitter: Emitter}} body
* @param {Object} [options] See description at Range.setOptions
* @constructor Range
* @extends Component
*/
constructor(body, options) {
super();
const now = moment$2().hours(0).minutes(0).seconds(0).milliseconds(0);
const start = now.clone().add(-3, "days").valueOf();
const end = now.clone().add(3, "days").valueOf();
this.millisecondsPerPixelCache = undefined;
if (options === undefined) {
this.start = start;
this.end = end;
} else {
this.start = options.start || start;
this.end = options.end || end;
}
this.rolling = false;
this.body = body;
this.deltaDifference = 0;
this.scaleOffset = 0;
this.startToFront = false;
this.endToFront = true;
// default options
this.defaultOptions = {
rtl: false,
start: null,
end: null,
moment: moment$2,
direction: "horizontal", // 'horizontal' or 'vertical'
moveable: true,
zoomable: true,
min: null,
max: null,
zoomMin: 10, // milliseconds
zoomMax: 1000 * 60 * 60 * 24 * 365 * 10000, // milliseconds
rollingMode: {
follow: false,
offset: 0.5,
},
};
this.options = availableUtils.extend({}, this.defaultOptions);
this.props = {
touch: {},
};
this.animationTimer = null;
// drag listeners for dragging
this.body.emitter.on("panstart", this._onDragStart.bind(this));
this.body.emitter.on("panmove", this._onDrag.bind(this));
this.body.emitter.on("panend", this._onDragEnd.bind(this));
// mouse wheel for zooming
this.body.emitter.on("mousewheel", this._onMouseWheel.bind(this));
// pinch to zoom
this.body.emitter.on("touch", this._onTouch.bind(this));
this.body.emitter.on("pinch", this._onPinch.bind(this));
// on click of rolling mode button
this.body.dom.rollingModeBtn.addEventListener(
"click",
this.startRolling.bind(this),
);
this.setOptions(options);
}
/**
* Set options for the range controller
* @param {Object} options Available options:
* {number | Date | String} start Start date for the range
* {number | Date | String} end End date for the range
* {number} min Minimum value for start
* {number} max Maximum value for end
* {number} zoomMin Set a minimum value for
* (end - start).
* {number} zoomMax Set a maximum value for
* (end - start).
* {boolean} moveable Enable moving of the range
* by dragging. True by default
* {boolean} zoomable Enable zooming of the range
* by pinching/scrolling. True by default
*/
setOptions(options) {
if (options) {
// copy the options that we know
const fields = [
"animation",
"direction",
"min",
"max",
"zoomMin",
"zoomMax",
"moveable",
"zoomable",
"moment",
"activate",
"hiddenDates",
"zoomKey",
"zoomFriction",
"rtl",
"showCurrentTime",
"rollingMode",
"horizontalScroll",
"horizontalScrollKey",
"horizontalScrollInvert",
"verticalScroll",
];
availableUtils.selectiveExtend(fields, this.options, options);
if (options.rollingMode && options.rollingMode.follow) {
this.startRolling();
}
if ("start" in options || "end" in options) {
// apply a new range. both start and end are optional
this.setRange(options.start, options.end);
}
}
}
/**
* Start auto refreshing the current time bar
*/
startRolling() {
const me = this;
/**
* Updates the current time.
*/
function update() {
me.stopRolling();
me.rolling = true;
let interval = me.end - me.start;
const t = availableUtils.convert(new Date(), "Date").valueOf();
const rollingModeOffset =
(me.options.rollingMode && me.options.rollingMode.offset) || 0.5;
const start = t - interval * rollingModeOffset;
const end = t + interval * (1 - rollingModeOffset);
const options = {
animation: false,
};
me.setRange(start, end, options);
// determine interval to refresh
const scale = me.conversion(me.body.domProps.center.width).scale;
interval = 1 / scale / 10;
if (interval < 30) interval = 30;
if (interval > 1000) interval = 1000;
me.body.dom.rollingModeBtn.style.visibility = "hidden";
// start a renderTimer to adjust for the new time
me.currentTimeTimer = setTimeout(update, interval);
}
update();
}
/**
* Stop auto refreshing the current time bar
*/
stopRolling() {
if (this.currentTimeTimer !== undefined) {
clearTimeout(this.currentTimeTimer);
this.rolling = false;
this.body.dom.rollingModeBtn.style.visibility = "visible";
}
}
/**
* Set a new start and end range
* @param {Date | number | string} start
* @param {Date | number | string} end
* @param {Object} options Available options:
* {boolean | {duration: number, easingFunction: string}} [animation=false]
* If true, the range is animated
* smoothly to the new window. An object can be
* provided to specify duration and easing function.
* Default duration is 500 ms, and default easing
* function is 'easeInOutQuad'.
* {boolean} [byUser=false]
* {Event} event Mouse event
* @param {Function} callback a callback function to be executed at the end of this function
* @param {Function} frameCallback a callback function executed each frame of the range animation.
* The callback will be passed three parameters:
* {number} easeCoefficient an easing coefficent
* {boolean} willDraw If true the caller will redraw after the callback completes
* {boolean} done If true then animation is ending after the current frame
* @return {void}
*/
setRange(start, end, options, callback, frameCallback) {
if (!options) {
options = {};
}
if (options.byUser !== true) {
options.byUser = false;
}
const me = this;
const finalStart =
start != undefined ? availableUtils.convert(start, "Date").valueOf() : null;
const finalEnd =
end != undefined ? availableUtils.convert(end, "Date").valueOf() : null;
this._cancelAnimation();
this.millisecondsPerPixelCache = undefined;
if (options.animation) {
// true or an Object
const initStart = this.start;
const initEnd = this.end;
const duration =
typeof options.animation === "object" && "duration" in options.animation
? options.animation.duration
: 500;
const easingName =
typeof options.animation === "object" &&
"easingFunction" in options.animation
? options.animation.easingFunction
: "easeInOutQuad";
const easingFunction = availableUtils.easingFunctions[easingName];
if (!easingFunction) {
throw new Error(
`Unknown easing function ${JSON.stringify(easingName)}. Choose from: ${Object.keys(availableUtils.easingFunctions).join(", ")}`,
);
}
const initTime = Date.now();
let anyChanged = false;
const next = () => {
if (!me.props.touch.dragging) {
const now = Date.now();
const time = now - initTime;
const ease = easingFunction(time / duration);
const done = time > duration;
const s =
done || finalStart === null
? finalStart
: initStart + (finalStart - initStart) * ease;
const e =
done || finalEnd === null
? finalEnd
: initEnd + (finalEnd - initEnd) * ease;
changed = me._applyRange(s, e);
updateHiddenDates(
me.options.moment,
me.body,
me.options.hiddenDates,
);
anyChanged = anyChanged || changed;
const params = {
start: new Date(me.start),
end: new Date(me.end),
byUser: options.byUser,
event: options.event,
};
if (frameCallback) {
frameCallback(ease, changed, done);
}
if (changed) {
me.body.emitter.emit("rangechange", params);
}
if (done) {
if (anyChanged) {
me.body.emitter.emit("rangechanged", params);
if (callback) {
return callback();
}
}
} else {
// animate with as high as possible frame rate, leave 20 ms in between
// each to prevent the browser from blocking
me.animationTimer = setTimeout(next, 20);
}
}
};
return next();
} else {
var changed = this._applyRange(finalStart, finalEnd);
updateHiddenDates(
this.options.moment,
this.body,
this.options.hiddenDates,
);
if (changed) {
const params = {
start: new Date(this.start),
end: new Date(this.end),
byUser: options.byUser,
event: options.event,
};
this.body.emitter.emit("rangechange", params);
clearTimeout(me.timeoutID);
me.timeoutID = setTimeout(() => {
me.body.emitter.emit("rangechanged", params);
}, 200);
if (callback) {
return callback();
}
}
}
}
/**
* Get the number of milliseconds per pixel.
*
* @returns {undefined|number}
*/
getMillisecondsPerPixel() {
if (this.millisecondsPerPixelCache === undefined) {
this.millisecondsPerPixelCache =
(this.end - this.start) / this.body.dom.center.clientWidth;
}
return this.millisecondsPerPixelCache;
}
/**
* Stop an animation
* @private
*/
_cancelAnimation() {
if (this.animationTimer) {
clearTimeout(this.animationTimer);
this.animationTimer = null;
}
}
/**
* Set a new start and end range. This method is the same as setRange, but
* does not trigger a range change and range changed event, and it returns
* true when the range is changed
* @param {number} [start]
* @param {number} [end]
* @return {boolean} changed
* @private
*/
_applyRange(start, end) {
let newStart =
start != null ? availableUtils.convert(start, "Date").valueOf() : this.start;
let newEnd = end != null ? availableUtils.convert(end, "Date").valueOf() : this.end;
const max =
this.options.max != null
? availableUtils.convert(this.options.max, "Date").valueOf()
: null;
const min =
this.options.min != null
? availableUtils.convert(this.options.min, "Date").valueOf()
: null;
let diff;
// check for valid number
if (isNaN(newStart) || newStart === null) {
throw new Error(`Invalid start "${start}"`);
}
if (isNaN(newEnd) || newEnd === null) {
throw new Error(`Invalid end "${end}"`);
}
// prevent end < start
if (newEnd < newStart) {
newEnd = newStart;
}
// prevent start < min
if (min !== null) {
if (newStart < min) {
diff = min - newStart;
newStart += diff;
newEnd += diff;
// prevent end > max
if (max != null) {
if (newEnd > max) {
newEnd = max;
}
}
}
}
// prevent end > max
if (max !== null) {
if (newEnd > max) {
diff = newEnd - max;
newStart -= diff;
newEnd -= diff;
// prevent start < min
if (min != null) {
if (newStart < min) {
newStart = min;
}
}
}
}
// prevent (end-start) < zoomMin
if (this.options.zoomMin !== null) {
let zoomMin = parseFloat(this.options.zoomMin);
if (zoomMin < 0) {
zoomMin = 0;
}
if (newEnd - newStart < zoomMin) {
// compensate for a scale of 0.5 ms
const compensation = 0.5;
if (
this.end - this.start === zoomMin &&
newStart >= this.start - compensation &&
newEnd <= this.end
) {
// ignore this action, we are already zoomed to the minimum
newStart = this.start;
newEnd = this.end;
} else {
// zoom to the minimum
diff = zoomMin - (newEnd - newStart);
newStart -= diff / 2;
newEnd += diff / 2;
}
}
}
// prevent (end-start) > zoomMax
if (this.options.zoomMax !== null) {
let zoomMax = parseFloat(this.options.zoomMax);
if (zoomMax < 0) {
zoomMax = 0;
}
if (newEnd - newStart > zoomMax) {
if (
this.end - this.start === zoomMax &&
newStart < this.start &&
newEnd > this.end
) {
// ignore this action, we are already zoomed to the maximum
newStart = this.start;
newEnd = this.end;
} else {
// zoom to the maximum
diff = newEnd - newStart - zoomMax;
newStart += diff / 2;
newEnd -= diff / 2;
}
}
}
const changed = this.start != newStart || this.end != newEnd;
// if the new range does NOT overlap with the old range, emit checkRangedItems to avoid not showing ranged items (ranged meaning has end time, not necessarily of type Range)
if (
!(
(newStart >= this.start && newStart <= this.end) ||
(newEnd >= this.start && newEnd <= this.end)
) &&
!(
(this.start >= newStart && this.start <= newEnd) ||
(this.end >= newStart && this.end <= newEnd)
)
) {
this.body.emitter.emit("checkRangedItems");
}
this.start = newStart;
this.end = newEnd;
return changed;
}
/**
* Retrieve the current range.
* @return {Object} An object with start and end properties
*/
getRange() {
return {
start: this.start,
end: this.end,
};
}
/**
* Calculate the conversion offset and scale for current range, based on
* the provided width
* @param {number} width
* @param {number} [totalHidden=0]
* @returns {{offset: number, scale: number}} conversion
*/
conversion(width, totalHidden) {
return Range.conversion(this.start, this.end, width, totalHidden);
}
/**
* Static method to calculate the conversion offset and scale for a range,
* based on the provided start, end, and width
* @param {number} start
* @param {number} end
* @param {number} width
* @param {number} [totalHidden=0]
* @returns {{offset: number, scale: number}} conversion
*/
static conversion(start, end, width, totalHidden) {
if (totalHidden === undefined) {
totalHidden = 0;
}
if (width != 0 && end - start != 0) {
return {
offset: start,
scale: width / (end - start - totalHidden),
};
} else {
return {
offset: 0,
scale: 1,
};
}
}
/**
* Start dragging horizontally or vertically
* @param {Event} event
* @private
*/
_onDragStart(event) {
this.deltaDifference = 0;
this.previousDelta = 0;
// only allow dragging when configured as movable
if (!this.options.moveable) return;
// only start dragging when the mouse is inside the current range
if (!this._isInsideRange(event)) return;
// refuse to drag when we where pinching to prevent the timeline make a jump
// when releasing the fingers in opposite order from the touch screen
if (!this.props.touch.allowDragging) return;
this.stopRolling();
this.props.touch.start = this.start;
this.props.touch.end = this.end;
this.props.touch.dragging = true;
if (this.body.dom.root) {
this.body.dom.root.style.cursor = "move";
}
}
/**
* Perform dragging operation
* @param {Event} event
* @private
*/
_onDrag(event) {
if (!event) return;
if (!this.props.touch.dragging) return;
// only allow dragging when configured as movable
if (!this.options.moveable) return;
// TODO: this may be redundant in hammerjs2
// refuse to drag when we where pinching to prevent the timeline make a jump
// when releasing the fingers in opposite order from the touch screen
if (!this.props.touch.allowDragging) return;
const direction = this.options.direction;
validateDirection(direction);
let delta = direction == "horizontal" ? event.deltaX : event.deltaY;
delta -= this.deltaDifference;
let interval = this.props.touch.end - this.props.touch.start;
// normalize dragging speed if cutout is in between.
const duration = getHiddenDurationBetween(
this.body.hiddenDates,
this.start,
this.end,
);
interval -= duration;
const width =
direction == "horizontal"
? this.body.domProps.center.width
: this.body.domProps.center.height;
let diffRange;
if (this.options.rtl) {
diffRange = (delta / width) * interval;
} else {
diffRange = (-delta / width) * interval;
}
const newStart = this.props.touch.start + diffRange;
const newEnd = this.props.touch.end + diffRange;
// snapping times away from hidden zones
const safeStart = snapAwayFromHidden(
this.body.hiddenDates,
newStart,
this.previousDelta - delta,
true,
);
const safeEnd = snapAwayFromHidden(
this.body.hiddenDates,
newEnd,
this.previousDelta - delta,
true,
);
if (safeStart != newStart || safeEnd != newEnd) {
this.deltaDifference += delta;
this.props.touch.start = safeStart;
this.props.touch.end = safeEnd;
this._onDrag(event);
return;
}
this.previousDelta = delta;
this._applyRange(newStart, newEnd);
const startDate = new Date(this.start);
const endDate = new Date(this.end);
// fire a rangechange event
this.body.emitter.emit("rangechange", {
start: startDate,
end: endDate,
byUser: true,
event,
});
// fire a panmove event
this.body.emitter.emit("panmove");
}
/**
* Stop dragging operation
* @param {event} event
* @private
*/
_onDragEnd(event) {
if (!this.props.touch.dragging) return;
// only allow dragging when configured as movable
if (!this.options.moveable) return;
// TODO: this may be redundant in hammerjs2
// refuse to drag when we where pinching to prevent the timeline make a jump
// when releasing the fingers in opposite order from the touch screen
if (!this.props.touch.allowDragging) return;
this.props.touch.dragging = false;
if (this.body.dom.root) {
this.body.dom.root.style.cursor = "auto";
}
// fire a rangechanged event
this.body.emitter.emit("rangechanged", {
start: new Date(this.start),
end: new Date(this.end),
byUser: true,
event,
});
}
/**
* Event handler for mouse wheel event, used to zoom
* Code from http://adomas.org/javascript-mouse-wheel/
* @param {Event} event
* @private
*/
_onMouseWheel(event) {
// retrieve delta
let delta = 0;
if (event.wheelDelta) {
/* IE/Opera. */
delta = event.wheelDelta / 120;
} else if (event.detail) {
/* Mozilla case. */
// In Mozilla, sign of delta is different than in IE.
// Also, delta is multiple of 3.
delta = -event.detail / 3;
} else if (ev