vis-timeline
Version:
Create a fully customizable, interactive timeline with items and ranges.
1,512 lines (1,348 loc) • 647 kB
JavaScript
/**
* vis-timeline and vis-graph2d
* https://visjs.github.io/vis-timeline/
*
* Create a fully customizable, interactive timeline with items and ranges.
*
* @version 7.7.3
* @date 2023-10-27T17:57:57.604Z
*
* @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.
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('moment'), require('vis-util/esnext/umd/vis-util.js'), require('vis-data/esnext/umd/vis-data.js'), require('xss'), require('uuid'), require('component-emitter'), require('propagating-hammerjs'), require('@egjs/hammerjs'), require('keycharm')) :
typeof define === 'function' && define.amd ? define(['exports', 'moment', 'vis-util/esnext/umd/vis-util.js', 'vis-data/esnext/umd/vis-data.js', 'xss', 'uuid', 'component-emitter', 'propagating-hammerjs', '@egjs/hammerjs', 'keycharm'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.vis = global.vis || {}, global.moment, global.vis, global.vis, global.filterXSS, global.uuid, global.Emitter, global.propagating, global.Hammer, global.keycharm));
})(this, (function (exports, moment$4, util, esnext, xssFilter, uuid, Emitter, PropagatingHammer, Hammer$1, keycharm) {
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var util__namespace = /*#__PURE__*/_interopNamespaceDefault(util);
function getDefaultExportFromCjs (x) {
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
}
// first check if moment.js is already loaded in the browser window, if so,
// use this instance. Else, load via commonjs.
//
// Note: This doesn't work in ESM.
var moment$2 = (typeof window !== 'undefined') && window['moment'] || moment$4;
var moment$3 = /*@__PURE__*/getDefaultExportFromCjs(moment$2);
// 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".
*/
function isDataViewLike(obj) {
if(!obj) {
return false;
}
let idProp = obj.idProp ?? obj._idProp;
if(!idProp) {
return false;
}
return esnext.isDataViewLike(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 - Value of unknown type.
* @param type - Name of the desired type.
*
* @returns 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 (util.isString(object) && !isNaN(Date.parse(object))) {
return moment$4(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 " + util.getType(object) + " to type " + type
);
} else {
throw e;
}
}
case "Moment":
if (util.isNumber(object)) {
return moment$4(object);
}
if (object instanceof Date) {
return moment$4(object.valueOf());
} else if (moment$4.isMoment(object)) {
return moment$4(object);
}
if (util.isString(object)) {
match = ASPDateRegex.exec(object);
if (match) {
// object is an ASP date
return moment$4(Number(match[1])); // parse number
}
match = NumericRegex.exec(object);
if (match) {
return moment$4(Number(object));
}
return moment$4(object); // parse string
} else {
throw new TypeError(
"Cannot convert object of type " + util.getType(object) + " to type " + type
);
}
case "ISODate":
if (util.isNumber(object)) {
return new Date(object);
} else if (object instanceof Date) {
return object.toISOString();
} else if (moment$4.isMoment(object)) {
return object.toDate().toISOString();
} else if (util.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$4(object).format(); // ISO 8601
}
} else {
throw new Error(
"Cannot convert object of type " +
util.getType(object) +
" to type ISODate"
);
}
case "ASPDate":
if (util.isNumber(object)) {
return "/Date(" + object + ")/";
} else if (object instanceof Date || moment$4.isMoment(object)) {
return "/Date(" + object.valueOf() + ")/";
} else if (util.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 " +
util.getType(object) +
" to type ASPDate"
);
}
default:
throw new Error(`Unknown type ${type}`);
}
}
/**
* Create a Data Set like wrapper to seamlessly coerce data types.
*
* @param rawDS - The Data Set with raw uncoerced data.
* @param type - A record assigning a data type to property name.
*
* @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 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 esnext.DataSet({ fieldId: idProp });
const pipe = esnext.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 (string) => customXSS.process(string);
};
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__namespace,
convert,
setupXSSProtection
};
Object.defineProperty(availableUtils, 'xss', {
get: function() {
return configuredXSSProtection;
}
});
/** Prototype for visual components */
class Component {
/**
* @param {{dom: Object, domProps: Object, emitter: Emitter, range: Range}} [body]
* @param {Object} [options]
*/
constructor(body, options) { // eslint-disable-line no-unused-vars
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) {
const 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;
const runUntil = end.clone();
switch (hiddenDates[i].repeat) {
case "daily": // case of time
if (startDate.day() != endDate.day()) {
offset = 1;
}
startDate.dayOfYear(start.dayOfYear());
startDate.year(start.year());
startDate.subtract(7,'days');
endDate.dayOfYear(start.dayOfYear());
endDate.year(start.year());
endDate.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.date(start.date());
startDate.month(start.month());
startDate.year(start.year());
endDate = startDate.clone();
// force
startDate.day(day);
endDate.day(day);
endDate.add(dayOffset,'days');
startDate.subtract(1,'weeks');
endDate.subtract(1,'weeks');
runUntil.add(1, 'weeks');
break;
}
case "monthly":
if (startDate.month() != endDate.month()) {
offset = 1;
}
startDate.month(start.month());
startDate.year(start.year());
startDate.subtract(1,'months');
endDate.month(start.month());
endDate.year(start.year());
endDate.subtract(1,'months');
endDate.add(offset,'months');
runUntil.add(1, 'months');
break;
case "yearly":
if (startDate.year() != endDate.year()) {
offset = 1;
}
startDate.year(start.year());
startDate.subtract(1,'years');
endDate.year(start.year());
endDate.subtract(1,'years');
endDate.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.add(1, 'days');
endDate.add(1, 'days');
break;
case "weekly":
startDate.add(1, 'weeks');
endDate.add(1, 'weeks');
break;
case "monthly":
startDate.add(1, 'months');
endDate.add(1, 'months');
break;
case "yearly":
startDate.add(1, 'y');
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$3().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$3,
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'
];
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.sta