UNPKG

vis-timeline

Version:

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

1,512 lines (1,348 loc) 647 kB
/** * 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