| // [LICENSE TBD] |
| /* Copied and altered from http://cal-heatmap.com/ , alterations around: |
| * - tuning tooltips |
| * - supporting multi-colors scales |
| * - legend format |
| * - UTC handling |
| */ |
| |
| /* eslint-disable */ |
| |
| import d3tip from 'd3-tip'; |
| import './d3tip.css'; |
| |
| var d3 = typeof require === 'function' ? require('d3') : window.d3; |
| |
| var d3 = typeof require === 'function' ? require('d3') : window.d3; |
| |
| var CalHeatMap = function () { |
| 'use strict'; |
| |
| var self = this; |
| self.tip = d3tip() |
| .attr('class', 'd3-tip') |
| .direction('n') |
| .offset([-5, 0]) |
| .html( |
| d => ` |
| ${self.options.timeFormatter(d.t)}: <strong>${self.options.valueFormatter(d.v)}</strong> |
| `, |
| ); |
| self.legendTip = d3tip() |
| .attr('class', 'd3-tip') |
| .direction('n') |
| .offset([-5, 0]) |
| .html(d => self.options.valueFormatter(d)); |
| |
| this.allowedDataType = ['json', 'csv', 'tsv', 'txt']; |
| |
| // Default settings |
| this.options = { |
| // selector string of the container to append the graph to |
| // Accept any string value accepted by document.querySelector or CSS3 |
| // or an Element object |
| itemSelector: '#cal-heatmap', |
| |
| // Whether to paint the calendar on init() |
| // Used by testsuite to reduce testing time |
| paintOnLoad: true, |
| |
| // ================================================ |
| // DOMAIN |
| // ================================================ |
| |
| // Number of domain to display on the graph |
| range: 12, |
| |
| // Size of each cell, in pixel |
| cellSize: 10, |
| |
| // Padding between each cell, in pixel |
| cellPadding: 2, |
| |
| // For rounded subdomain rectangles, in pixels |
| cellRadius: 0, |
| |
| domainGutter: 2, |
| |
| domainMargin: [0, 0, 0, 0], |
| |
| valueFormatter: d => d, |
| |
| timeFormatter: d => d, |
| |
| domain: 'hour', |
| |
| subDomain: 'min', |
| |
| // Number of columns to split the subDomains to |
| // If not null, will takes precedence over rowLimit |
| colLimit: null, |
| |
| // Number of rows to split the subDomains to |
| // Will be ignored if colLimit is not null |
| rowLimit: null, |
| |
| // First day of the week is Monday |
| // 0 to start the week on Sunday |
| weekStartOnMonday: true, |
| |
| // Start date of the graph |
| // @default now |
| start: new Date(), |
| |
| minDate: null, |
| |
| maxDate: null, |
| |
| // ================================================ |
| // DATA |
| // ================================================ |
| |
| // Data source |
| // URL, where to fetch the original datas |
| data: '', |
| |
| // Data type |
| // Default: json |
| dataType: this.allowedDataType[0], |
| |
| // Payload sent when using POST http method |
| // Leave to null (default) for GET request |
| // Expect a string, formatted like "a=b;c=d" |
| dataPostPayload: null, |
| |
| // Additional headers sent when requesting data |
| // Expect an object formatted like: |
| // { 'X-CSRF-TOKEN': 'token' } |
| dataRequestHeaders: null, |
| |
| // Whether to consider missing date:value from the datasource |
| // as equal to 0, or just leave them as missing |
| considerMissingDataAsZero: false, |
| |
| // Load remote data on calendar creation |
| // When false, the calendar will be left empty |
| loadOnInit: true, |
| |
| // Calendar orientation |
| // false: display domains side by side |
| // true : display domains one under the other |
| verticalOrientation: false, |
| |
| // Domain dynamic width/height |
| // The width on a domain depends on the number of |
| domainDynamicDimension: true, |
| |
| // Domain Label properties |
| label: { |
| // valid: top, right, bottom, left |
| position: 'bottom', |
| |
| // Valid: left, center, right |
| // Also valid are the direct svg values: start, middle, end |
| align: 'center', |
| |
| // By default, there is no margin/padding around the label |
| offset: { |
| x: 0, |
| y: 0, |
| }, |
| |
| rotate: null, |
| |
| // Used only on vertical orientation |
| width: 100, |
| |
| // Used only on horizontal orientation |
| height: null, |
| }, |
| |
| // ================================================ |
| // LEGEND |
| // ================================================ |
| |
| // Threshold for the legend |
| legend: [10, 20, 30, 40], |
| |
| // Whether to display the legend |
| displayLegend: true, |
| |
| legendCellSize: 10, |
| |
| legendCellPadding: 2, |
| |
| legendMargin: [0, 0, 0, 0], |
| |
| // Legend vertical position |
| // top: place legend above calendar |
| // bottom: place legend below the calendar |
| legendVerticalPosition: 'bottom', |
| |
| // Legend horizontal position |
| // accepted values: left, center, right |
| legendHorizontalPosition: 'left', |
| |
| // Legend rotation |
| // accepted values: horizontal, vertical |
| legendOrientation: 'horizontal', |
| |
| // Objects holding all the heatmap different colors |
| // null to disable, and use the default css styles |
| // |
| // Examples: |
| // legendColors: { |
| // min: "green", |
| // max: "red", |
| // empty: "#ffffff", |
| // base: "grey", |
| // overflow: "red", |
| // colorScaler: null, |
| // } |
| legendColors: null, |
| |
| // ================================================ |
| // HIGHLIGHT |
| // ================================================ |
| |
| // List of dates to highlight |
| // Valid values: |
| // - []: don't highlight anything |
| // - "now": highlight the current date |
| // - an array of Date objects: highlight the specified dates |
| highlight: [], |
| |
| // ================================================ |
| // TEXT FORMATTING / i18n |
| // ================================================ |
| |
| // Name of the items to represent in the calendar |
| itemName: ['item', 'items'], |
| |
| // Formatting of the domain label |
| // @default: null, will use the formatting according to domain type |
| // Accept a string used as specifier by d3.time.format() |
| // or a function |
| // |
| // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting |
| // for accepted date formatting used by d3.time.format() |
| domainLabelFormat: null, |
| |
| // Formatting of the title displayed when hovering a subDomain cell |
| subDomainTitleFormat: { |
| empty: '{date}', |
| filled: '{count} {name} {connector} {date}', |
| }, |
| |
| // Formatting of the {date} used in subDomainTitleFormat |
| // @default: null, will use the formatting according to subDomain type |
| // Accept a string used as specifier by d3.time.format() |
| // or a function |
| // |
| // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting |
| // for accepted date formatting used by d3.time.format() |
| subDomainDateFormat: null, |
| |
| // Formatting of the text inside each subDomain cell |
| // @default: null, no text |
| // Accept a string used as specifier by d3.time.format() |
| // or a function |
| // |
| // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting |
| // for accepted date formatting used by d3.time.format() |
| subDomainTextFormat: null, |
| |
| // Formatting of the title displayed when hovering a legend cell |
| legendTitleFormat: { |
| lower: 'less than {min} {name}', |
| inner: 'between {down} and {up} {name}', |
| upper: 'more than {max} {name}', |
| }, |
| |
| // Animation duration, in ms |
| animationDuration: 500, |
| |
| nextSelector: false, |
| |
| previousSelector: false, |
| |
| itemNamespace: 'cal-heatmap', |
| |
| tooltip: false, |
| |
| // ================================================ |
| // EVENTS CALLBACK |
| // ================================================ |
| |
| // Callback when clicking on a time block |
| onClick: null, |
| |
| // Callback after painting the empty calendar |
| // Can be used to trigger an API call, once the calendar is ready to be filled |
| afterLoad: null, |
| |
| // Callback after loading the next domain in the calendar |
| afterLoadNextDomain: null, |
| |
| // Callback after loading the previous domain in the calendar |
| afterLoadPreviousDomain: null, |
| |
| // Callback after finishing all actions on the calendar |
| onComplete: null, |
| |
| // Callback after fetching the datas, but before applying them to the calendar |
| // Used mainly to convert the datas if they're not formatted like expected |
| // Takes the fetched "data" object as argument, must return a json object |
| // formatted like {timestamp:count, timestamp2:count2}, |
| afterLoadData: function (data) { |
| return data; |
| }, |
| |
| // Callback triggered after calling and completing update(). |
| afterUpdate: null, |
| |
| // Callback triggered after calling next(). |
| // The `status` argument is equal to true if there is no |
| // more next domain to load |
| // |
| // This callback is also executed once, after calling previous(), |
| // only when the max domain is reached |
| onMaxDomainReached: null, |
| |
| // Callback triggered after calling previous(). |
| // The `status` argument is equal to true if there is no |
| // more previous domain to load |
| // |
| // This callback is also executed once, after calling next(), |
| // only when the min domain is reached |
| onMinDomainReached: null, |
| }; |
| |
| this._domainType = { |
| min: { |
| name: 'minute', |
| level: 10, |
| maxItemNumber: 60, |
| defaultRowNumber: 10, |
| defaultColumnNumber: 6, |
| row: function (d) { |
| return self.getSubDomainRowNumber(d); |
| }, |
| column: function (d) { |
| return self.getSubDomainColumnNumber(d); |
| }, |
| position: { |
| x: function (d) { |
| return Math.floor(d.getMinutes() / self._domainType.min.row(d)); |
| }, |
| y: function (d) { |
| return d.getMinutes() % self._domainType.min.row(d); |
| }, |
| }, |
| format: { |
| date: '%H:%M, %A %B %-e, %Y', |
| legend: '', |
| connector: 'at', |
| }, |
| extractUnit: function (d) { |
| return new Date( |
| d.getFullYear(), |
| d.getMonth(), |
| d.getDate(), |
| d.getHours(), |
| d.getMinutes(), |
| ).getTime(); |
| }, |
| }, |
| hour: { |
| name: 'hour', |
| level: 20, |
| maxItemNumber: function (d) { |
| switch (self.options.domain) { |
| case 'day': |
| return 24; |
| case 'week': |
| return 24 * 7; |
| case 'month': |
| return 24 * (self.options.domainDynamicDimension ? self.getDayCountInMonth(d) : 31); |
| } |
| }, |
| defaultRowNumber: 6, |
| defaultColumnNumber: function (d) { |
| switch (self.options.domain) { |
| case 'day': |
| return 4; |
| case 'week': |
| return 28; |
| case 'month': |
| return self.options.domainDynamicDimension ? self.getDayCountInMonth(d) : 31; |
| } |
| }, |
| row: function (d) { |
| return self.getSubDomainRowNumber(d); |
| }, |
| column: function (d) { |
| return self.getSubDomainColumnNumber(d); |
| }, |
| position: { |
| x: function (d) { |
| if (self.options.domain === 'month') { |
| if (self.options.colLimit > 0 || self.options.rowLimit > 0) { |
| return Math.floor( |
| (d.getHours() + (d.getDate() - 1) * 24) / self._domainType.hour.row(d), |
| ); |
| } |
| return Math.floor(d.getHours() / self._domainType.hour.row(d)) + (d.getDate() - 1) * 4; |
| } else if (self.options.domain === 'week') { |
| if (self.options.colLimit > 0 || self.options.rowLimit > 0) { |
| return Math.floor( |
| (d.getHours() + self.getWeekDay(d) * 24) / self._domainType.hour.row(d), |
| ); |
| } |
| return Math.floor(d.getHours() / self._domainType.hour.row(d)) + self.getWeekDay(d) * 4; |
| } |
| return Math.floor(d.getHours() / self._domainType.hour.row(d)); |
| }, |
| y: function (d) { |
| var p = d.getHours(); |
| if (self.options.colLimit > 0 || self.options.rowLimit > 0) { |
| switch (self.options.domain) { |
| case 'month': |
| p += (d.getDate() - 1) * 24; |
| break; |
| case 'week': |
| p += self.getWeekDay(d) * 24; |
| break; |
| } |
| } |
| return Math.floor(p % self._domainType.hour.row(d)); |
| }, |
| }, |
| format: { |
| date: '%Hh, %A %B %-e, %Y', |
| legend: '%H:00', |
| connector: 'at', |
| }, |
| extractUnit: function (d) { |
| return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime(); |
| }, |
| }, |
| day: { |
| name: 'day', |
| level: 30, |
| maxItemNumber: function (d) { |
| switch (self.options.domain) { |
| case 'week': |
| return 7; |
| case 'month': |
| return self.options.domainDynamicDimension ? self.getDayCountInMonth(d) : 31; |
| case 'year': |
| return self.options.domainDynamicDimension ? self.getDayCountInYear(d) : 366; |
| } |
| }, |
| defaultColumnNumber: function (d) { |
| d = new Date(d); |
| switch (self.options.domain) { |
| case 'week': |
| return 1; |
| case 'month': |
| return self.options.domainDynamicDimension && !self.options.verticalOrientation |
| ? self.getWeekNumber(new Date(d.getFullYear(), d.getMonth() + 1, 0)) - |
| self.getWeekNumber(d) + |
| 1 |
| : 6; |
| case 'year': |
| return self.options.domainDynamicDimension |
| ? self.getWeekNumber(new Date(d.getFullYear(), 11, 31)) - |
| self.getWeekNumber(new Date(d.getFullYear(), 0)) + |
| 1 |
| : 54; |
| } |
| }, |
| defaultRowNumber: 7, |
| row: function (d) { |
| return self.getSubDomainRowNumber(d); |
| }, |
| column: function (d) { |
| return self.getSubDomainColumnNumber(d); |
| }, |
| position: { |
| x: function (d) { |
| switch (self.options.domain) { |
| case 'week': |
| return Math.floor(self.getWeekDay(d) / self._domainType.day.row(d)); |
| case 'month': |
| if (self.options.colLimit > 0 || self.options.rowLimit > 0) { |
| return Math.floor((d.getDate() - 1) / self._domainType.day.row(d)); |
| } |
| return ( |
| self.getWeekNumber(d) - self.getWeekNumber(new Date(d.getFullYear(), d.getMonth())) |
| ); |
| case 'year': |
| if (self.options.colLimit > 0 || self.options.rowLimit > 0) { |
| return Math.floor((self.getDayOfYear(d) - 1) / self._domainType.day.row(d)); |
| } |
| return self.getWeekNumber(d); |
| } |
| }, |
| y: function (d) { |
| var p = self.getWeekDay(d); |
| if (self.options.colLimit > 0 || self.options.rowLimit > 0) { |
| switch (self.options.domain) { |
| case 'year': |
| p = self.getDayOfYear(d) - 1; |
| break; |
| case 'week': |
| p = self.getWeekDay(d); |
| break; |
| case 'month': |
| p = d.getDate() - 1; |
| break; |
| } |
| } |
| return Math.floor(p % self._domainType.day.row(d)); |
| }, |
| }, |
| format: { |
| date: '%A %B %-e, %Y', |
| legend: '%e %b', |
| connector: 'on', |
| }, |
| extractUnit: function (d) { |
| return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); |
| }, |
| }, |
| week: { |
| name: 'week', |
| level: 40, |
| maxItemNumber: 54, |
| defaultColumnNumber: function (d) { |
| d = new Date(d); |
| switch (self.options.domain) { |
| case 'year': |
| return self._domainType.week.maxItemNumber; |
| case 'month': |
| return self.options.domainDynamicDimension |
| ? self.getWeekNumber(new Date(d.getFullYear(), d.getMonth() + 1, 0)) - |
| self.getWeekNumber(d) |
| : 5; |
| } |
| }, |
| defaultRowNumber: 1, |
| row: function (d) { |
| return self.getSubDomainRowNumber(d); |
| }, |
| column: function (d) { |
| return self.getSubDomainColumnNumber(d); |
| }, |
| position: { |
| x: function (d) { |
| switch (self.options.domain) { |
| case 'year': |
| return Math.floor(self.getWeekNumber(d) / self._domainType.week.row(d)); |
| case 'month': |
| return Math.floor(self.getMonthWeekNumber(d) / self._domainType.week.row(d)); |
| } |
| }, |
| y: function (d) { |
| return self.getWeekNumber(d) % self._domainType.week.row(d); |
| }, |
| }, |
| format: { |
| date: '%B Week #%W', |
| legend: '%B Week #%W', |
| connector: 'in', |
| }, |
| extractUnit: function (d) { |
| var dt = new Date(d.getFullYear(), d.getMonth(), d.getDate()); |
| // According to ISO-8601, week number computation are based on week starting on Monday |
| var weekDay = dt.getDay() - (self.options.weekStartOnMonday ? 1 : 0); |
| if (weekDay < 0) { |
| weekDay = 6; |
| } |
| dt.setDate(dt.getDate() - weekDay); |
| return dt.getTime(); |
| }, |
| }, |
| month: { |
| name: 'month', |
| level: 50, |
| maxItemNumber: 12, |
| defaultColumnNumber: 12, |
| defaultRowNumber: 1, |
| row: function () { |
| return self.getSubDomainRowNumber(); |
| }, |
| column: function () { |
| return self.getSubDomainColumnNumber(); |
| }, |
| position: { |
| x: function (d) { |
| return Math.floor(d.getMonth() / self._domainType.month.row(d)); |
| }, |
| y: function (d) { |
| return d.getMonth() % self._domainType.month.row(d); |
| }, |
| }, |
| format: { |
| date: '%B %Y', |
| legend: '%B', |
| connector: 'in', |
| }, |
| extractUnit: function (d) { |
| return new Date(d.getFullYear(), d.getMonth()).getTime(); |
| }, |
| }, |
| year: { |
| name: 'year', |
| level: 60, |
| row: function () { |
| return self.options.rowLimit || 1; |
| }, |
| column: function () { |
| return self.options.colLimit || 1; |
| }, |
| position: { |
| x: function () { |
| return 1; |
| }, |
| y: function () { |
| return 1; |
| }, |
| }, |
| format: { |
| date: '%Y', |
| legend: '%Y', |
| connector: 'in', |
| }, |
| extractUnit: function (d) { |
| return new Date(d.getFullYear()).getTime(); |
| }, |
| }, |
| }; |
| |
| for (var type in this._domainType) { |
| if (this._domainType.hasOwnProperty(type)) { |
| var d = this._domainType[type]; |
| this._domainType['x_' + type] = { |
| name: 'x_' + type, |
| level: d.type, |
| maxItemNumber: d.maxItemNumber, |
| defaultRowNumber: d.defaultRowNumber, |
| defaultColumnNumber: d.defaultColumnNumber, |
| row: d.column, |
| column: d.row, |
| position: { |
| x: d.position.y, |
| y: d.position.x, |
| }, |
| format: d.format, |
| extractUnit: d.extractUnit, |
| }; |
| } |
| } |
| |
| // Record the address of the last inserted domain when browsing |
| this.lastInsertedSvg = null; |
| |
| this._completed = false; |
| |
| // Record all the valid domains |
| // Each domain value is a timestamp in milliseconds |
| this._domains = d3.map(); |
| |
| this.graphDim = { |
| width: 0, |
| height: 0, |
| }; |
| |
| this.legendDim = { |
| width: 0, |
| height: 0, |
| }; |
| |
| this.NAVIGATE_LEFT = 1; |
| this.NAVIGATE_RIGHT = 2; |
| |
| // Various update mode when using the update() API |
| this.RESET_ALL_ON_UPDATE = 0; |
| this.RESET_SINGLE_ON_UPDATE = 1; |
| this.APPEND_ON_UPDATE = 2; |
| |
| this.DEFAULT_LEGEND_MARGIN = 10; |
| |
| this.root = null; |
| this.tooltip = null; |
| |
| this._maxDomainReached = false; |
| this._minDomainReached = false; |
| |
| this.domainPosition = new DomainPosition(); |
| this.Legend = null; |
| this.legendScale = null; |
| |
| // List of domains that are skipped because of DST |
| // All times belonging to these domains should be re-assigned to the previous domain |
| this.DSTDomain = []; |
| |
| /** |
| * Display the graph for the first time |
| * @return bool True if the calendar is created |
| */ |
| this._init = function () { |
| self |
| .getDomain(self.options.start) |
| .map(function (d) { |
| return d.getTime(); |
| }) |
| .map(function (d) { |
| self._domains.set( |
| d, |
| self.getSubDomain(d).map(function (d) { |
| return { t: self._domainType[self.options.subDomain].extractUnit(d), v: null }; |
| }), |
| ); |
| }); |
| |
| self.root = d3 |
| .select(self.options.itemSelector) |
| .append('svg') |
| .attr('class', 'cal-heatmap-container'); |
| |
| self.root.attr('x', 0).attr('y', 0).append('svg').attr('class', 'graph'); |
| |
| self.Legend = new Legend(self); |
| |
| if (self.options.paintOnLoad) { |
| _initCalendar(); |
| } |
| self.root.call(self.tip); |
| self.root.call(self.legendTip); |
| |
| return true; |
| }; |
| |
| function _initCalendar() { |
| self.verticalDomainLabel = |
| self.options.label.position === 'top' || self.options.label.position === 'bottom'; |
| |
| self.domainVerticalLabelHeight = |
| self.options.label.height === null |
| ? Math.max(25, self.options.cellSize * 2) |
| : self.options.label.height; |
| self.domainHorizontalLabelWidth = 0; |
| |
| if (self.options.domainLabelFormat === '' && self.options.label.height === null) { |
| self.domainVerticalLabelHeight = 0; |
| } |
| |
| if (!self.verticalDomainLabel) { |
| self.domainVerticalLabelHeight = 0; |
| self.domainHorizontalLabelWidth = self.options.label.width; |
| } |
| |
| self.paint(); |
| |
| // =========================================================================// |
| // ATTACHING DOMAIN NAVIGATION EVENT // |
| // =========================================================================// |
| if (self.options.nextSelector !== false) { |
| d3.select(self.options.nextSelector).on('click.' + self.options.itemNamespace, function () { |
| d3.event.preventDefault(); |
| return self.loadNextDomain(1); |
| }); |
| } |
| |
| if (self.options.previousSelector !== false) { |
| d3.select(self.options.previousSelector).on( |
| 'click.' + self.options.itemNamespace, |
| function () { |
| d3.event.preventDefault(); |
| return self.loadPreviousDomain(1); |
| }, |
| ); |
| } |
| |
| self.Legend.redraw(self.graphDim.width - self.options.domainGutter - self.options.cellPadding); |
| self.afterLoad(); |
| |
| var domains = self.getDomainKeys(); |
| |
| // Fill the graph with some datas |
| if (self.options.loadOnInit) { |
| self.getDatas( |
| self.options.data, |
| new Date(domains[0]), |
| self.getSubDomain(domains[domains.length - 1]).pop(), |
| function () { |
| self.fill(); |
| self.onComplete(); |
| }, |
| ); |
| } else { |
| self.onComplete(); |
| } |
| |
| self.checkIfMinDomainIsReached(domains[0]); |
| self.checkIfMaxDomainIsReached(self.getNextDomain().getTime()); |
| } |
| |
| // Return the width of the domain block, without the domain gutter |
| // @param int d Domain start timestamp |
| function w(d, outer) { |
| var width = |
| self.options.cellSize * self._domainType[self.options.subDomain].column(d) + |
| self.options.cellPadding * self._domainType[self.options.subDomain].column(d); |
| if (arguments.length === 2 && outer === true) { |
| return (width += |
| self.domainHorizontalLabelWidth + |
| self.options.domainGutter + |
| self.options.domainMargin[1] + |
| self.options.domainMargin[3]); |
| } |
| return width; |
| } |
| |
| // Return the height of the domain block, without the domain gutter |
| function h(d, outer) { |
| var height = |
| self.options.cellSize * self._domainType[self.options.subDomain].row(d) + |
| self.options.cellPadding * self._domainType[self.options.subDomain].row(d); |
| if (arguments.length === 2 && outer === true) { |
| height += |
| self.options.domainGutter + |
| self.domainVerticalLabelHeight + |
| self.options.domainMargin[0] + |
| self.options.domainMargin[2]; |
| } |
| return height; |
| } |
| |
| /** |
| * |
| * |
| * @param int navigationDir |
| */ |
| this.paint = function (navigationDir) { |
| var options = self.options; |
| |
| if (arguments.length === 0) { |
| navigationDir = false; |
| } |
| |
| // Painting all the domains |
| var domainSvg = self.root |
| .select('.graph') |
| .selectAll('.graph-domain') |
| .data( |
| function () { |
| var data = self.getDomainKeys(); |
| return navigationDir === self.NAVIGATE_LEFT ? data.reverse() : data; |
| }, |
| function (d) { |
| return d; |
| }, |
| ); |
| var enteringDomainDim = 0; |
| var exitingDomainDim = 0; |
| |
| // =========================================================================// |
| // PAINTING DOMAIN // |
| // =========================================================================// |
| |
| var svg = domainSvg |
| .enter() |
| .append('svg') |
| .attr('width', function (d) { |
| return w(d, true); |
| }) |
| .attr('height', function (d) { |
| return h(d, true); |
| }) |
| .attr('x', function (d) { |
| if (options.verticalOrientation) { |
| self.graphDim.width = Math.max(self.graphDim.width, w(d, true)); |
| return 0; |
| } else { |
| return getDomainPosition(d, self.graphDim, 'width', w(d, true)); |
| } |
| }) |
| .attr('y', function (d) { |
| if (options.verticalOrientation) { |
| return getDomainPosition(d, self.graphDim, 'height', h(d, true)); |
| } else { |
| self.graphDim.height = Math.max(self.graphDim.height, h(d, true)); |
| return 0; |
| } |
| }) |
| .attr('class', function (d) { |
| var classname = 'graph-domain'; |
| var date = new Date(d); |
| switch (options.domain) { |
| case 'hour': |
| classname += ' h_' + date.getHours(); |
| /* falls through */ |
| case 'day': |
| classname += ' d_' + date.getDate() + ' dy_' + date.getDay(); |
| /* falls through */ |
| case 'week': |
| classname += ' w_' + self.getWeekNumber(date); |
| /* falls through */ |
| case 'month': |
| classname += ' m_' + (date.getMonth() + 1); |
| /* falls through */ |
| case 'year': |
| classname += ' y_' + date.getFullYear(); |
| } |
| return classname; |
| }); |
| self.lastInsertedSvg = svg; |
| |
| function getDomainPosition(domainIndex, graphDim, axis, domainDim) { |
| var tmp = 0; |
| switch (navigationDir) { |
| case false: |
| tmp = graphDim[axis]; |
| |
| graphDim[axis] += domainDim; |
| self.domainPosition.setPosition(domainIndex, tmp); |
| return tmp; |
| |
| case self.NAVIGATE_RIGHT: |
| self.domainPosition.setPosition(domainIndex, graphDim[axis]); |
| |
| enteringDomainDim = domainDim; |
| exitingDomainDim = self.domainPosition.getPositionFromIndex(1); |
| |
| self.domainPosition.shiftRightBy(exitingDomainDim); |
| return graphDim[axis]; |
| |
| case self.NAVIGATE_LEFT: |
| tmp = -domainDim; |
| |
| enteringDomainDim = -tmp; |
| exitingDomainDim = graphDim[axis] - self.domainPosition.getLast(); |
| |
| self.domainPosition.setPosition(domainIndex, tmp); |
| self.domainPosition.shiftLeftBy(enteringDomainDim); |
| return tmp; |
| } |
| } |
| |
| svg |
| .append('rect') |
| .attr('width', function (d) { |
| return w(d, true) - options.domainGutter - options.cellPadding; |
| }) |
| .attr('height', function (d) { |
| return h(d, true) - options.domainGutter - options.cellPadding; |
| }) |
| .attr('class', 'domain-background'); |
| |
| // =========================================================================// |
| // PAINTING SUBDOMAINS // |
| // =========================================================================// |
| var subDomainSvgGroup = svg |
| .append('svg') |
| .attr('x', function () { |
| if (options.label.position === 'left') { |
| return self.domainHorizontalLabelWidth + options.domainMargin[3]; |
| } else { |
| return options.domainMargin[3]; |
| } |
| }) |
| .attr('y', function () { |
| if (options.label.position === 'top') { |
| return self.domainVerticalLabelHeight + options.domainMargin[0]; |
| } else { |
| return options.domainMargin[0]; |
| } |
| }) |
| .attr('class', 'graph-subdomain-group'); |
| var rect = subDomainSvgGroup |
| .selectAll('g') |
| .data(function (d) { |
| return self._domains.get(d); |
| }) |
| .enter() |
| .append('g'); |
| rect |
| .append('rect') |
| .attr('class', function (d) { |
| return ( |
| 'graph-rect' + |
| self.getHighlightClassName(d.t) + |
| (options.onClick !== null ? ' hover_cursor' : '') |
| ); |
| }) |
| .attr('width', options.cellSize) |
| .attr('height', options.cellSize) |
| .attr('x', function (d) { |
| return self.positionSubDomainX(d.t); |
| }) |
| .attr('y', function (d) { |
| return self.positionSubDomainY(d.t); |
| }) |
| .on('click', function (d) { |
| if (options.onClick !== null) { |
| return self.onClick(new Date(d.t), d.v); |
| } |
| }) |
| .call(function (selection) { |
| if (options.cellRadius > 0) { |
| selection.attr('rx', options.cellRadius).attr('ry', options.cellRadius); |
| } |
| |
| if ( |
| self.legendScale !== null && |
| options.legendColors !== null && |
| options.legendColors.hasOwnProperty('base') |
| ) { |
| selection.attr('fill', options.legendColors.base); |
| } |
| |
| if (options.tooltip) { |
| selection |
| .on('mouseover', function (d) { |
| self.tip.show(d, this); |
| }) |
| .on('mouseout', function () { |
| self.tip.hide(d); |
| }); |
| } |
| }); |
| |
| // Appending a title to each subdomain |
| if (!options.tooltip) { |
| rect.append('title').text(function (d) { |
| return self.formatDate(new Date(d.t), options.subDomainDateFormat); |
| }); |
| } |
| |
| // =========================================================================// |
| // PAINTING LABEL // |
| // =========================================================================// |
| if (options.domainLabelFormat !== '') { |
| svg |
| .append('text') |
| .attr('class', 'graph-label') |
| .attr('y', function (d) { |
| var y = options.domainMargin[0]; |
| switch (options.label.position) { |
| case 'top': |
| y += self.domainVerticalLabelHeight / 2; |
| break; |
| case 'bottom': |
| y += h(d) + self.domainVerticalLabelHeight / 2; |
| } |
| |
| return ( |
| y + |
| options.label.offset.y * |
| ((options.label.rotate === 'right' && options.label.position === 'right') || |
| (options.label.rotate === 'left' && options.label.position === 'left') |
| ? -1 |
| : 1) |
| ); |
| }) |
| .attr('x', function (d) { |
| var x = options.domainMargin[3]; |
| switch (options.label.position) { |
| case 'right': |
| x += w(d); |
| break; |
| case 'bottom': |
| case 'top': |
| x += w(d) / 2; |
| } |
| |
| if (options.label.align === 'right') { |
| return ( |
| x + |
| self.domainHorizontalLabelWidth - |
| options.label.offset.x * (options.label.rotate === 'right' ? -1 : 1) |
| ); |
| } |
| return x + options.label.offset.x; |
| }) |
| .attr('text-anchor', function () { |
| switch (options.label.align) { |
| case 'start': |
| case 'left': |
| return 'start'; |
| case 'end': |
| case 'right': |
| return 'end'; |
| default: |
| return 'middle'; |
| } |
| }) |
| .attr('dominant-baseline', function () { |
| return self.verticalDomainLabel ? 'middle' : 'top'; |
| }) |
| .text(function (d) { |
| return self.formatDate(new Date(d), options.domainLabelFormat); |
| }) |
| .call(domainRotate); |
| } |
| |
| function domainRotate(selection) { |
| switch (options.label.rotate) { |
| case 'right': |
| selection.attr('transform', function (d) { |
| var s = 'rotate(90), '; |
| switch (options.label.position) { |
| case 'right': |
| s += 'translate(-' + w(d) + ' , -' + w(d) + ')'; |
| break; |
| case 'left': |
| s += 'translate(0, -' + self.domainHorizontalLabelWidth + ')'; |
| break; |
| } |
| |
| return s; |
| }); |
| break; |
| case 'left': |
| selection.attr('transform', function (d) { |
| var s = 'rotate(270), '; |
| switch (options.label.position) { |
| case 'right': |
| s += 'translate(-' + (w(d) + self.domainHorizontalLabelWidth) + ' , ' + w(d) + ')'; |
| break; |
| case 'left': |
| s += |
| 'translate(-' + |
| self.domainHorizontalLabelWidth + |
| ' , ' + |
| self.domainHorizontalLabelWidth + |
| ')'; |
| break; |
| } |
| |
| return s; |
| }); |
| break; |
| } |
| } |
| |
| // =========================================================================// |
| // PAINTING DOMAIN SUBDOMAIN CONTENT // |
| // =========================================================================// |
| if (options.subDomainTextFormat !== null) { |
| rect |
| .append('text') |
| .attr('class', function (d) { |
| return 'subdomain-text' + self.getHighlightClassName(d.t); |
| }) |
| .attr('x', function (d) { |
| return self.positionSubDomainX(d.t) + options.cellSize / 2; |
| }) |
| .attr('y', function (d) { |
| return self.positionSubDomainY(d.t) + options.cellSize / 2; |
| }) |
| .attr('text-anchor', 'middle') |
| .attr('dominant-baseline', 'central') |
| .text(function (d) { |
| return self.formatDate(new Date(d.t), options.subDomainTextFormat); |
| }); |
| } |
| |
| // =========================================================================// |
| // ANIMATION // |
| // =========================================================================// |
| |
| if (navigationDir !== false) { |
| domainSvg |
| .transition() |
| .duration(options.animationDuration) |
| .attr('x', function (d) { |
| return options.verticalOrientation ? 0 : self.domainPosition.getPosition(d); |
| }) |
| .attr('y', function (d) { |
| return options.verticalOrientation ? self.domainPosition.getPosition(d) : 0; |
| }); |
| } |
| |
| var tempWidth = self.graphDim.width; |
| var tempHeight = self.graphDim.height; |
| |
| if (options.verticalOrientation) { |
| self.graphDim.height += enteringDomainDim - exitingDomainDim; |
| } else { |
| self.graphDim.width += enteringDomainDim - exitingDomainDim; |
| } |
| |
| // At the time of exit, domainsWidth and domainsHeight already automatically shifted |
| domainSvg |
| .exit() |
| .transition() |
| .duration(options.animationDuration) |
| .attr('x', function (d) { |
| if (options.verticalOrientation) { |
| return 0; |
| } else { |
| switch (navigationDir) { |
| case self.NAVIGATE_LEFT: |
| return Math.min(self.graphDim.width, tempWidth); |
| case self.NAVIGATE_RIGHT: |
| return -w(d, true); |
| } |
| } |
| }) |
| .attr('y', function (d) { |
| if (options.verticalOrientation) { |
| switch (navigationDir) { |
| case self.NAVIGATE_LEFT: |
| return Math.min(self.graphDim.height, tempHeight); |
| case self.NAVIGATE_RIGHT: |
| return -h(d, true); |
| } |
| } else { |
| return 0; |
| } |
| }) |
| .remove(); |
| |
| // Resize the root container |
| self.resize(); |
| }; |
| }; |
| |
| CalHeatMap.prototype = { |
| /** |
| * Validate and merge user settings with default settings |
| * |
| * @param {object} settings User settings |
| * @return {bool} False if settings contains error |
| */ |
| /* jshint maxstatements:false */ |
| init: function (settings) { |
| 'use strict'; |
| |
| var parent = this; |
| |
| var options = (parent.options = mergeRecursive(parent.options, settings)); |
| |
| // Fatal errors |
| // Stop script execution on error |
| validateDomainType(); |
| validateSelector(options.itemSelector, false, 'itemSelector'); |
| |
| if (parent.allowedDataType.indexOf(options.dataType) === -1) { |
| throw new Error("The data type '" + options.dataType + "' is not valid data type"); |
| } |
| |
| if (d3.select(options.itemSelector)[0][0] === null) { |
| throw new Error( |
| "The node '" + options.itemSelector + "' specified in itemSelector does not exists", |
| ); |
| } |
| |
| try { |
| validateSelector(options.nextSelector, true, 'nextSelector'); |
| validateSelector(options.previousSelector, true, 'previousSelector'); |
| } catch (error) { |
| console.log(error.message); |
| return false; |
| } |
| |
| // If other settings contains error, will fallback to default |
| |
| if (!settings.hasOwnProperty('subDomain')) { |
| this.options.subDomain = getOptimalSubDomain(settings.domain); |
| } |
| |
| if (typeof options.itemNamespace !== 'string' || options.itemNamespace === '') { |
| console.log('itemNamespace can not be empty, falling back to cal-heatmap'); |
| options.itemNamespace = 'cal-heatmap'; |
| } |
| |
| // Don't touch these settings |
| var s = [ |
| 'data', |
| 'onComplete', |
| 'onClick', |
| 'afterLoad', |
| 'afterLoadData', |
| 'afterLoadPreviousDomain', |
| 'afterLoadNextDomain', |
| 'afterUpdate', |
| ]; |
| |
| for (var k in s) { |
| if (settings.hasOwnProperty(s[k])) { |
| options[s[k]] = settings[s[k]]; |
| } |
| } |
| |
| options.subDomainDateFormat = |
| typeof options.subDomainDateFormat === 'string' || |
| typeof options.subDomainDateFormat === 'function' |
| ? options.subDomainDateFormat |
| : this._domainType[options.subDomain].format.date; |
| options.domainLabelFormat = |
| typeof options.domainLabelFormat === 'string' || |
| typeof options.domainLabelFormat === 'function' |
| ? options.domainLabelFormat |
| : this._domainType[options.domain].format.legend; |
| options.subDomainTextFormat = |
| (typeof options.subDomainTextFormat === 'string' && options.subDomainTextFormat !== '') || |
| typeof options.subDomainTextFormat === 'function' |
| ? options.subDomainTextFormat |
| : null; |
| options.domainMargin = expandMarginSetting(options.domainMargin); |
| options.legendMargin = expandMarginSetting(options.legendMargin); |
| options.highlight = parent.expandDateSetting(options.highlight); |
| options.itemName = expandItemName(options.itemName); |
| options.colLimit = parseColLimit(options.colLimit); |
| options.rowLimit = parseRowLimit(options.rowLimit); |
| if (!settings.hasOwnProperty('legendMargin')) { |
| autoAddLegendMargin(); |
| } |
| autoAlignLabel(); |
| |
| /** |
| * Validate that a queryString is valid |
| * |
| * @param {Element|string|bool} selector The queryString to test |
| * @param {bool} canBeFalse Whether false is an accepted and valid value |
| * @param {string} name Name of the tested selector |
| * @throws {Error} If the selector is not valid |
| * @return {bool} True if the selector is a valid queryString |
| */ |
| function validateSelector(selector, canBeFalse, name) { |
| if ( |
| ((canBeFalse && selector === false) || |
| selector instanceof Element || |
| typeof selector === 'string') && |
| selector !== '' |
| ) { |
| return true; |
| } |
| throw new Error('The ' + name + ' is not valid'); |
| } |
| |
| /** |
| * Return the optimal subDomain for the specified domain |
| * |
| * @param {string} domain a domain name |
| * @return {string} the subDomain name |
| */ |
| function getOptimalSubDomain(domain) { |
| switch (domain) { |
| case 'year': |
| return 'month'; |
| case 'month': |
| return 'day'; |
| case 'week': |
| return 'day'; |
| case 'day': |
| return 'hour'; |
| default: |
| return 'min'; |
| } |
| } |
| |
| /** |
| * Ensure that the domain and subdomain are valid |
| * |
| * @throw {Error} when domain or subdomain are not valid |
| * @return {bool} True if domain and subdomain are valid and compatible |
| */ |
| function validateDomainType() { |
| if ( |
| !parent._domainType.hasOwnProperty(options.domain) || |
| options.domain === 'min' || |
| options.domain.substring(0, 2) === 'x_' |
| ) { |
| throw new Error("The domain '" + options.domain + "' is not valid"); |
| } |
| |
| if (!parent._domainType.hasOwnProperty(options.subDomain) || options.subDomain === 'year') { |
| throw new Error("The subDomain '" + options.subDomain + "' is not valid"); |
| } |
| |
| if (parent._domainType[options.domain].level <= parent._domainType[options.subDomain].level) { |
| throw new Error( |
| "'" + options.subDomain + "' is not a valid subDomain to '" + options.domain + "'", |
| ); |
| } |
| |
| return true; |
| } |
| |
| /** |
| * Fine-tune the label alignement depending on its position |
| * |
| * @return void |
| */ |
| function autoAlignLabel() { |
| // Auto-align label, depending on it's position |
| if ( |
| !settings.hasOwnProperty('label') || |
| (settings.hasOwnProperty('label') && !settings.label.hasOwnProperty('align')) |
| ) { |
| switch (options.label.position) { |
| case 'left': |
| options.label.align = 'right'; |
| break; |
| case 'right': |
| options.label.align = 'left'; |
| break; |
| default: |
| options.label.align = 'center'; |
| } |
| |
| if (options.label.rotate === 'left') { |
| options.label.align = 'right'; |
| } else if (options.label.rotate === 'right') { |
| options.label.align = 'left'; |
| } |
| } |
| |
| if ( |
| !settings.hasOwnProperty('label') || |
| (settings.hasOwnProperty('label') && !settings.label.hasOwnProperty('offset')) |
| ) { |
| if (options.label.position === 'left' || options.label.position === 'right') { |
| options.label.offset = { |
| x: 10, |
| y: 15, |
| }; |
| } |
| } |
| } |
| |
| /** |
| * If not specified, add some margin around the legend depending on its position |
| * |
| * @return void |
| */ |
| function autoAddLegendMargin() { |
| switch (options.legendVerticalPosition) { |
| case 'top': |
| options.legendMargin[2] = parent.DEFAULT_LEGEND_MARGIN; |
| break; |
| case 'bottom': |
| options.legendMargin[0] = parent.DEFAULT_LEGEND_MARGIN; |
| break; |
| case 'middle': |
| case 'center': |
| options.legendMargin[options.legendHorizontalPosition === 'right' ? 3 : 1] = |
| parent.DEFAULT_LEGEND_MARGIN; |
| } |
| } |
| |
| /** |
| * Expand a number of an array of numbers to an usable 4 values array |
| * |
| * @param {integer|array} value |
| * @return {array} array |
| */ |
| function expandMarginSetting(value) { |
| if (typeof value === 'number') { |
| value = [value]; |
| } |
| |
| if (!Array.isArray(value)) { |
| console.log('Margin only takes an integer or an array of integers'); |
| value = [0]; |
| } |
| |
| switch (value.length) { |
| case 1: |
| return [value[0], value[0], value[0], value[0]]; |
| case 2: |
| return [value[0], value[1], value[0], value[1]]; |
| case 3: |
| return [value[0], value[1], value[2], value[1]]; |
| case 4: |
| return value; |
| default: |
| return value.slice(0, 4); |
| } |
| } |
| |
| /** |
| * Convert a string to an array like [singular-form, plural-form] |
| * |
| * @param {string|array} value Date to convert |
| * @return {array} An array like [singular-form, plural-form] |
| */ |
| function expandItemName(value) { |
| if (typeof value === 'string') { |
| return [value, value + (value !== '' ? 's' : '')]; |
| } |
| |
| if (Array.isArray(value)) { |
| if (value.length === 1) { |
| return [value[0], value[0] + 's']; |
| } else if (value.length > 2) { |
| return value.slice(0, 2); |
| } |
| |
| return value; |
| } |
| |
| return ['item', 'items']; |
| } |
| |
| function parseColLimit(value) { |
| return value > 0 ? value : null; |
| } |
| |
| function parseRowLimit(value) { |
| if (value > 0 && options.colLimit > 0) { |
| console.log('colLimit and rowLimit are mutually exclusive, rowLimit will be ignored'); |
| return null; |
| } |
| return value > 0 ? value : null; |
| } |
| |
| return this._init(); |
| }, |
| |
| /** |
| * Convert a keyword or an array of keyword/date to an array of date objects |
| * |
| * @param {string|array|Date} value Data to convert |
| * @return {array} An array of Dates |
| */ |
| expandDateSetting: function (value) { |
| 'use strict'; |
| |
| if (!Array.isArray(value)) { |
| value = [value]; |
| } |
| |
| return value |
| .map(function (data) { |
| if (data === 'now') { |
| return new Date(); |
| } |
| if (data instanceof Date) { |
| return data; |
| } |
| return false; |
| }) |
| .filter(function (d) { |
| return d !== false; |
| }); |
| }, |
| |
| /** |
| * Fill the calendar by coloring the cells |
| * |
| * @param array svg An array of html node to apply the transformation to (optional) |
| * It's used to limit the painting to only a subset of the calendar |
| * @return void |
| */ |
| fill: function (svg) { |
| 'use strict'; |
| |
| var parent = this; |
| var options = parent.options; |
| |
| if (arguments.length === 0) { |
| svg = parent.root.selectAll('.graph-domain'); |
| } |
| |
| var rect = svg |
| .selectAll('svg') |
| .selectAll('g') |
| .data(function (d) { |
| return parent._domains.get(d); |
| }); |
| /** |
| * Colorize the cell via a style attribute if enabled |
| */ |
| function addStyle(element) { |
| if (parent.legendScale === null) { |
| return false; |
| } |
| |
| element.attr('fill', function (d) { |
| if ( |
| d.v === null && |
| options.hasOwnProperty('considerMissingDataAsZero') && |
| !options.considerMissingDataAsZero |
| ) { |
| if (options.legendColors.hasOwnProperty('base')) { |
| return options.legendColors.base; |
| } |
| } |
| |
| if ( |
| options.legendColors !== null && |
| options.legendColors.hasOwnProperty('empty') && |
| (d.v === 0 || |
| (d.v === null && |
| options.hasOwnProperty('considerMissingDataAsZero') && |
| options.considerMissingDataAsZero)) |
| ) { |
| return options.legendColors.empty; |
| } |
| |
| if ( |
| d.v < 0 && |
| options.legend[0] > 0 && |
| options.legendColors !== null && |
| options.legendColors.hasOwnProperty('overflow') |
| ) { |
| return options.legendColors.overflow; |
| } |
| |
| return parent.legendScale(Math.min(d.v, options.legend[options.legend.length - 1])); |
| }); |
| } |
| |
| rect |
| .transition() |
| .duration(options.animationDuration) |
| .select('rect') |
| .attr('class', function (d) { |
| var htmlClass = parent.getHighlightClassName(d.t).trim().split(' '); |
| var pastDate = parent.dateIsLessThan(d.t, new Date()); |
| var sameDate = parent.dateIsEqual(d.t, new Date()); |
| |
| if ( |
| parent.legendScale === null || |
| (d.v === null && |
| options.hasOwnProperty('considerMissingDataAsZero') && |
| !options.considerMissingDataAsZero && |
| !options.legendColors.hasOwnProperty('base')) |
| ) { |
| htmlClass.push('graph-rect'); |
| } |
| |
| if (sameDate) { |
| htmlClass.push('now'); |
| } else if (!pastDate) { |
| htmlClass.push('future'); |
| } |
| |
| if (d.v !== null) { |
| htmlClass.push(parent.Legend.getClass(d.v, parent.legendScale === null)); |
| } else if (options.considerMissingDataAsZero && pastDate) { |
| htmlClass.push(parent.Legend.getClass(0, parent.legendScale === null)); |
| } |
| |
| if (options.onClick !== null) { |
| htmlClass.push('hover_cursor'); |
| } |
| |
| return htmlClass.join(' '); |
| }) |
| .call(addStyle); |
| |
| rect |
| .transition() |
| .duration(options.animationDuration) |
| .select('title') |
| .text(function (d) { |
| return parent.getSubDomainTitle(d); |
| }); |
| |
| function formatSubDomainText(element) { |
| if (typeof options.subDomainTextFormat === 'function') { |
| element.text(function (d) { |
| return options.subDomainTextFormat(d.t, d.v); |
| }); |
| } |
| } |
| |
| /** |
| * Change the subDomainText class if necessary |
| * Also change the text, e.g when text is representing the value |
| * instead of the date |
| */ |
| rect |
| .transition() |
| .duration(options.animationDuration) |
| .select('text') |
| .attr('class', function (d) { |
| return 'subdomain-text' + parent.getHighlightClassName(d.t); |
| }) |
| .call(formatSubDomainText); |
| }, |
| |
| /** |
| * Sprintf like function. |
| * Replaces placeholders {0} in string with values from provided object. |
| * |
| * @param string formatted String containing placeholders. |
| * @param object args Object with properties to replace placeholders in string. |
| * |
| * @return String |
| */ |
| formatStringWithObject: function (formatted, args) { |
| 'use strict'; |
| for (var prop in args) { |
| if (args.hasOwnProperty(prop)) { |
| var regexp = new RegExp('\\{' + prop + '\\}', 'gi'); |
| formatted = formatted.replace(regexp, args[prop]); |
| } |
| } |
| return formatted; |
| }, |
| |
| // =========================================================================// |
| // EVENTS CALLBACK // |
| // =========================================================================// |
| |
| /** |
| * Helper method for triggering event callback |
| * |
| * @param string eventName Name of the event to trigger |
| * @param array successArgs List of argument to pass to the callback |
| * @param boolean skip Whether to skip the event triggering |
| * @return mixed True when the triggering was skipped, false on error, else the callback function |
| */ |
| triggerEvent: function (eventName, successArgs, skip) { |
| 'use strict'; |
| |
| if ((arguments.length === 3 && skip) || this.options[eventName] === null) { |
| return true; |
| } |
| |
| if (typeof this.options[eventName] === 'function') { |
| if (typeof successArgs === 'function') { |
| successArgs = successArgs(); |
| } |
| return this.options[eventName].apply(this, successArgs); |
| } else { |
| console.log('Provided callback for ' + eventName + ' is not a function.'); |
| return false; |
| } |
| }, |
| |
| /** |
| * Event triggered on a mouse click on a subDomain cell |
| * |
| * @param Date d Date of the subdomain block |
| * @param int itemNb Number of items in that date |
| */ |
| onClick: function (d, itemNb) { |
| 'use strict'; |
| |
| return this.triggerEvent('onClick', [d, itemNb]); |
| }, |
| |
| /** |
| * Event triggered after drawing the calendar, byt before filling it with data |
| */ |
| afterLoad: function () { |
| 'use strict'; |
| |
| return this.triggerEvent('afterLoad'); |
| }, |
| |
| /** |
| * Event triggered after completing drawing and filling the calendar |
| */ |
| onComplete: function () { |
| 'use strict'; |
| |
| var response = this.triggerEvent('onComplete', [], this._completed); |
| this._completed = true; |
| return response; |
| }, |
| |
| /** |
| * Event triggered after shifting the calendar one domain back |
| * |
| * @param Date start Domain start date |
| * @param Date end Domain end date |
| */ |
| afterLoadPreviousDomain: function (start) { |
| 'use strict'; |
| |
| var parent = this; |
| return this.triggerEvent('afterLoadPreviousDomain', function () { |
| var subDomain = parent.getSubDomain(start); |
| return [subDomain.shift(), subDomain.pop()]; |
| }); |
| }, |
| |
| /** |
| * Event triggered after shifting the calendar one domain above |
| * |
| * @param Date start Domain start date |
| * @param Date end Domain end date |
| */ |
| afterLoadNextDomain: function (start) { |
| 'use strict'; |
| |
| var parent = this; |
| return this.triggerEvent('afterLoadNextDomain', function () { |
| var subDomain = parent.getSubDomain(start); |
| return [subDomain.shift(), subDomain.pop()]; |
| }); |
| }, |
| |
| /** |
| * Event triggered after loading the leftmost domain allowed by minDate |
| * |
| * @param boolean reached True if the leftmost domain was reached |
| */ |
| onMinDomainReached: function (reached) { |
| 'use strict'; |
| |
| this._minDomainReached = reached; |
| return this.triggerEvent('onMinDomainReached', [reached]); |
| }, |
| |
| /** |
| * Event triggered after loading the rightmost domain allowed by maxDate |
| * |
| * @param boolean reached True if the rightmost domain was reached |
| */ |
| onMaxDomainReached: function (reached) { |
| 'use strict'; |
| |
| this._maxDomainReached = reached; |
| return this.triggerEvent('onMaxDomainReached', [reached]); |
| }, |
| |
| checkIfMinDomainIsReached: function (date, upperBound) { |
| 'use strict'; |
| |
| if (this.minDomainIsReached(date)) { |
| this.onMinDomainReached(true); |
| } |
| |
| if (arguments.length === 2) { |
| if (this._maxDomainReached && !this.maxDomainIsReached(upperBound)) { |
| this.onMaxDomainReached(false); |
| } |
| } |
| }, |
| |
| checkIfMaxDomainIsReached: function (date, lowerBound) { |
| 'use strict'; |
| |
| if (this.maxDomainIsReached(date)) { |
| this.onMaxDomainReached(true); |
| } |
| |
| if (arguments.length === 2) { |
| if (this._minDomainReached && !this.minDomainIsReached(lowerBound)) { |
| this.onMinDomainReached(false); |
| } |
| } |
| }, |
| |
| afterUpdate: function () { |
| 'use strict'; |
| |
| return this.triggerEvent('afterUpdate'); |
| }, |
| |
| // =========================================================================// |
| // FORMATTER // |
| // =========================================================================// |
| |
| formatNumber: d3.format(',g'), |
| |
| formatDate: function (d, format) { |
| 'use strict'; |
| |
| if (arguments.length < 2) { |
| format = 'title'; |
| } |
| |
| if (typeof format === 'function') { |
| return format(d); |
| } else { |
| var f = d3.time.format(format); |
| return f(d); |
| } |
| }, |
| |
| getSubDomainTitle: function (d) { |
| 'use strict'; |
| |
| if (d.v === null && !this.options.considerMissingDataAsZero) { |
| return this.formatStringWithObject(this.options.subDomainTitleFormat.empty, { |
| date: this.formatDate(new Date(d.t), this.options.subDomainDateFormat), |
| }); |
| } else { |
| var value = d.v; |
| // Consider null as 0 |
| if (value === null && this.options.considerMissingDataAsZero) { |
| value = 0; |
| } |
| |
| return this.formatStringWithObject(this.options.subDomainTitleFormat.filled, { |
| count: this.formatNumber(value), |
| name: this.options.itemName[value !== 1 ? 1 : 0], |
| connector: this._domainType[this.options.subDomain].format.connector, |
| date: this.formatDate(new Date(d.t), this.options.subDomainDateFormat), |
| }); |
| } |
| }, |
| |
| // =========================================================================// |
| // DOMAIN NAVIGATION // |
| // =========================================================================// |
| |
| /** |
| * Shift the calendar one domain forward |
| * |
| * The new domain is loaded only if it's not beyond maxDate |
| * |
| * @param int n Number of domains to load |
| * @return bool True if the next domain was loaded, else false |
| */ |
| loadNextDomain: function (n) { |
| 'use strict'; |
| |
| if (this._maxDomainReached || n === 0) { |
| return false; |
| } |
| |
| var bound = this.loadNewDomains(this.NAVIGATE_RIGHT, this.getDomain(this.getNextDomain(), n)); |
| |
| this.afterLoadNextDomain(bound.end); |
| this.checkIfMaxDomainIsReached(this.getNextDomain().getTime(), bound.start); |
| |
| return true; |
| }, |
| |
| /** |
| * Shift the calendar one domain backward |
| * |
| * The previous domain is loaded only if it's not beyond the minDate |
| * |
| * @param int n Number of domains to load |
| * @return bool True if the previous domain was loaded, else false |
| */ |
| loadPreviousDomain: function (n) { |
| 'use strict'; |
| |
| if (this._minDomainReached || n === 0) { |
| return false; |
| } |
| |
| var bound = this.loadNewDomains( |
| this.NAVIGATE_LEFT, |
| this.getDomain(this.getDomainKeys()[0], -n).reverse(), |
| ); |
| |
| this.afterLoadPreviousDomain(bound.start); |
| this.checkIfMinDomainIsReached(bound.start, bound.end); |
| |
| return true; |
| }, |
| |
| loadNewDomains: function (direction, newDomains) { |
| 'use strict'; |
| |
| var parent = this; |
| var backward = direction === this.NAVIGATE_LEFT; |
| var i = -1; |
| var total = newDomains.length; |
| var domains = this.getDomainKeys(); |
| |
| function buildSubDomain(d) { |
| return { t: parent._domainType[parent.options.subDomain].extractUnit(d), v: null }; |
| } |
| |
| // Remove out of bound domains from list of new domains to prepend |
| while (++i < total) { |
| if (backward && this.minDomainIsReached(newDomains[i])) { |
| newDomains = newDomains.slice(0, i + 1); |
| break; |
| } |
| if (!backward && this.maxDomainIsReached(newDomains[i])) { |
| newDomains = newDomains.slice(0, i); |
| break; |
| } |
| } |
| |
| newDomains = newDomains.slice(-this.options.range); |
| |
| for (i = 0, total = newDomains.length; i < total; i += 1) { |
| this._domains.set( |
| newDomains[i].getTime(), |
| this.getSubDomain(newDomains[i]).map(buildSubDomain), |
| ); |
| |
| this._domains.remove(backward ? domains.pop() : domains.shift()); |
| } |
| |
| domains = this.getDomainKeys(); |
| |
| if (backward) { |
| newDomains = newDomains.reverse(); |
| } |
| |
| this.paint(direction); |
| |
| this.getDatas( |
| this.options.data, |
| newDomains[0], |
| this.getSubDomain(newDomains[newDomains.length - 1]).pop(), |
| function () { |
| parent.fill(parent.lastInsertedSvg); |
| }, |
| ); |
| |
| return { |
| start: newDomains[backward ? 0 : 1], |
| end: domains[domains.length - 1], |
| }; |
| }, |
| |
| /** |
| * Return whether a date is inside the scope determined by maxDate |
| * |
| * @param int datetimestamp The timestamp in ms to test |
| * @return bool True if the specified date correspond to the calendar upper bound |
| */ |
| maxDomainIsReached: function (datetimestamp) { |
| 'use strict'; |
| |
| return this.options.maxDate !== null && this.options.maxDate.getTime() < datetimestamp; |
| }, |
| |
| /** |
| * Return whether a date is inside the scope determined by minDate |
| * |
| * @param int datetimestamp The timestamp in ms to test |
| * @return bool True if the specified date correspond to the calendar lower bound |
| */ |
| minDomainIsReached: function (datetimestamp) { |
| 'use strict'; |
| |
| return this.options.minDate !== null && this.options.minDate.getTime() >= datetimestamp; |
| }, |
| |
| /** |
| * Return the list of the calendar's domain timestamp |
| * |
| * @return Array a sorted array of timestamp |
| */ |
| getDomainKeys: function () { |
| 'use strict'; |
| |
| return this._domains |
| .keys() |
| .map(function (d) { |
| return parseInt(d, 10); |
| }) |
| .sort(function (a, b) { |
| return a - b; |
| }); |
| }, |
| |
| // =========================================================================// |
| // POSITIONNING // |
| // =========================================================================// |
| |
| positionSubDomainX: function (d) { |
| 'use strict'; |
| |
| var index = this._domainType[this.options.subDomain].position.x(new Date(d)); |
| return index * this.options.cellSize + index * this.options.cellPadding; |
| }, |
| |
| positionSubDomainY: function (d) { |
| 'use strict'; |
| |
| var index = this._domainType[this.options.subDomain].position.y(new Date(d)); |
| return index * this.options.cellSize + index * this.options.cellPadding; |
| }, |
| |
| getSubDomainColumnNumber: function (d) { |
| 'use strict'; |
| |
| if (this.options.rowLimit > 0) { |
| var i = this._domainType[this.options.subDomain].maxItemNumber; |
| if (typeof i === 'function') { |
| i = i(d); |
| } |
| return Math.ceil(i / this.options.rowLimit); |
| } |
| |
| var j = this._domainType[this.options.subDomain].defaultColumnNumber; |
| if (typeof j === 'function') { |
| j = j(d); |
| } |
| return this.options.colLimit || j; |
| }, |
| |
| getSubDomainRowNumber: function (d) { |
| 'use strict'; |
| |
| if (this.options.colLimit > 0) { |
| var i = this._domainType[this.options.subDomain].maxItemNumber; |
| if (typeof i === 'function') { |
| i = i(d); |
| } |
| return Math.ceil(i / this.options.colLimit); |
| } |
| |
| var j = this._domainType[this.options.subDomain].defaultRowNumber; |
| if (typeof j === 'function') { |
| j = j(d); |
| } |
| return this.options.rowLimit || j; |
| }, |
| |
| /** |
| * Return a classname if the specified date should be highlighted |
| * |
| * @param timestamp date Date of the current subDomain |
| * @return String the highlight class |
| */ |
| getHighlightClassName: function (d) { |
| 'use strict'; |
| |
| d = new Date(d); |
| |
| if (this.options.highlight.length > 0) { |
| for (var i in this.options.highlight) { |
| if (this.dateIsEqual(this.options.highlight[i], d)) { |
| return this.isNow(this.options.highlight[i]) ? ' highlight-now' : ' highlight'; |
| } |
| } |
| } |
| return ''; |
| }, |
| |
| /** |
| * Return whether the specified date is now, |
| * according to the type of subdomain |
| * |
| * @param Date d The date to compare |
| * @return bool True if the date correspond to a subdomain cell |
| */ |
| isNow: function (d) { |
| 'use strict'; |
| |
| return this.dateIsEqual(d, new Date()); |
| }, |
| |
| /** |
| * Return whether 2 dates are equals |
| * This function is subdomain-aware, |
| * and dates comparison are dependent of the subdomain |
| * |
| * @param Date dateA First date to compare |
| * @param Date dateB Secon date to compare |
| * @return bool true if the 2 dates are equals |
| */ |
| /* jshint maxcomplexity: false */ |
| dateIsEqual: function (dateA, dateB) { |
| 'use strict'; |
| |
| if (!(dateA instanceof Date)) { |
| dateA = new Date(dateA); |
| } |
| |
| if (!(dateB instanceof Date)) { |
| dateB = new Date(dateB); |
| } |
| |
| switch (this.options.subDomain) { |
| case 'x_min': |
| case 'min': |
| return ( |
| dateA.getFullYear() === dateB.getFullYear() && |
| dateA.getMonth() === dateB.getMonth() && |
| dateA.getDate() === dateB.getDate() && |
| dateA.getHours() === dateB.getHours() && |
| dateA.getMinutes() === dateB.getMinutes() |
| ); |
| case 'x_hour': |
| case 'hour': |
| return ( |
| dateA.getFullYear() === dateB.getFullYear() && |
| dateA.getMonth() === dateB.getMonth() && |
| dateA.getDate() === dateB.getDate() && |
| dateA.getHours() === dateB.getHours() |
| ); |
| case 'x_day': |
| case 'day': |
| return ( |
| dateA.getFullYear() === dateB.getFullYear() && |
| dateA.getMonth() === dateB.getMonth() && |
| dateA.getDate() === dateB.getDate() |
| ); |
| case 'x_week': |
| case 'week': |
| return ( |
| dateA.getFullYear() === dateB.getFullYear() && |
| this.getWeekNumber(dateA) === this.getWeekNumber(dateB) |
| ); |
| case 'x_month': |
| case 'month': |
| return dateA.getFullYear() === dateB.getFullYear() && dateA.getMonth() === dateB.getMonth(); |
| default: |
| return false; |
| } |
| }, |
| |
| /** |
| * Returns wether or not dateA is less than or equal to dateB. This function is subdomain aware. |
| * Performs automatic conversion of values. |
| * @param dateA may be a number or a Date |
| * @param dateB may be a number or a Date |
| * @returns {boolean} |
| */ |
| dateIsLessThan: function (dateA, dateB) { |
| 'use strict'; |
| |
| if (!(dateA instanceof Date)) { |
| dateA = new Date(dateA); |
| } |
| |
| if (!(dateB instanceof Date)) { |
| dateB = new Date(dateB); |
| } |
| |
| function normalizedMillis(date, subdomain) { |
| switch (subdomain) { |
| case 'x_min': |
| case 'min': |
| return new Date( |
| date.getFullYear(), |
| date.getMonth(), |
| date.getDate(), |
| date.getHours(), |
| date.getMinutes(), |
| ).getTime(); |
| case 'x_hour': |
| case 'hour': |
| return new Date( |
| date.getFullYear(), |
| date.getMonth(), |
| date.getDate(), |
| date.getHours(), |
| ).getTime(); |
| case 'x_day': |
| case 'day': |
| return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); |
| case 'x_week': |
| case 'week': |
| case 'x_month': |
| case 'month': |
| return new Date(date.getFullYear(), date.getMonth()).getTime(); |
| default: |
| return date.getTime(); |
| } |
| } |
| |
| return ( |
| normalizedMillis(dateA, this.options.subDomain) < |
| normalizedMillis(dateB, this.options.subDomain) |
| ); |
| }, |
| |
| // =========================================================================// |
| // DATE COMPUTATION // |
| // =========================================================================// |
| |
| /** |
| * Return the day of the year for the date |
| * @param Date |
| * @return int Day of the year [1,366] |
| */ |
| getDayOfYear: d3.time.format('%j'), |
| |
| /** |
| * Return the week number of the year |
| * Monday as the first day of the week |
| * @return int Week number [0-53] |
| */ |
| getWeekNumber: function (d) { |
| 'use strict'; |
| |
| var f = this.options.weekStartOnMonday === true ? d3.time.format('%W') : d3.time.format('%U'); |
| return f(d); |
| }, |
| |
| /** |
| * Return the week number, relative to its month |
| * |
| * @param int|Date d Date or timestamp in milliseconds |
| * @return int Week number, relative to the month [0-5] |
| */ |
| getMonthWeekNumber: function (d) { |
| 'use strict'; |
| |
| if (typeof d === 'number') { |
| d = new Date(d); |
| } |
| |
| var monthFirstWeekNumber = this.getWeekNumber(new Date(d.getFullYear(), d.getMonth())); |
| return this.getWeekNumber(d) - monthFirstWeekNumber - 1; |
| }, |
| |
| /** |
| * Return the number of weeks in the dates' year |
| * |
| * @param int|Date d Date or timestamp in milliseconds |
| * @return int Number of weeks in the date's year |
| */ |
| getWeekNumberInYear: function (d) { |
| 'use strict'; |
| |
| if (typeof d === 'number') { |
| d = new Date(d); |
| } |
| }, |
| |
| /** |
| * Return the number of days in the date's month |
| * |
| * @param int|Date d Date or timestamp in milliseconds |
| * @return int Number of days in the date's month |
| */ |
| getDayCountInMonth: function (d) { |
| 'use strict'; |
| |
| return this.getEndOfMonth(d).getDate(); |
| }, |
| |
| /** |
| * Return the number of days in the date's year |
| * |
| * @param int|Date d Date or timestamp in milliseconds |
| * @return int Number of days in the date's year |
| */ |
| getDayCountInYear: function (d) { |
| 'use strict'; |
| |
| if (typeof d === 'number') { |
| d = new Date(d); |
| } |
| return new Date(d.getFullYear(), 1, 29).getMonth() === 1 ? 366 : 365; |
| }, |
| |
| /** |
| * Get the weekday from a date |
| * |
| * Return the week day number (0-6) of a date, |
| * depending on whether the week start on monday or sunday |
| * |
| * @param Date d |
| * @return int The week day number (0-6) |
| */ |
| getWeekDay: function (d) { |
| 'use strict'; |
| |
| if (this.options.weekStartOnMonday === false) { |
| return d.getDay(); |
| } |
| return d.getDay() === 0 ? 6 : d.getDay() - 1; |
| }, |
| |
| /** |
| * Get the last day of the month |
| * @param Date|int d Date or timestamp in milliseconds |
| * @return Date Last day of the month |
| */ |
| getEndOfMonth: function (d) { |
| 'use strict'; |
| |
| if (typeof d === 'number') { |
| d = new Date(d); |
| } |
| return new Date(d.getFullYear(), d.getMonth() + 1, 0); |
| }, |
| |
| /** |
| * |
| * @param Date date |
| * @param int count |
| * @param string step |
| * @return Date |
| */ |
| jumpDate: function (date, count, step) { |
| 'use strict'; |
| |
| var d = new Date(date); |
| switch (step) { |
| case 'hour': |
| d.setHours(d.getHours() + count); |
| break; |
| case 'day': |
| d.setHours(d.getHours() + count * 24); |
| break; |
| case 'week': |
| d.setHours(d.getHours() + count * 24 * 7); |
| break; |
| case 'month': |
| d.setMonth(d.getMonth() + count); |
| break; |
| case 'year': |
| d.setFullYear(d.getFullYear() + count); |
| } |
| |
| return new Date(d); |
| }, |
| |
| // =========================================================================// |
| // DOMAIN COMPUTATION // |
| // =========================================================================// |
| |
| /** |
| * Return all the minutes between 2 dates |
| * |
| * @param Date d date A date |
| * @param int|date range Number of minutes in the range, or a stop date |
| * @return array An array of minutes |
| */ |
| getMinuteDomain: function (d, range) { |
| 'use strict'; |
| |
| var start = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()); |
| var stop = null; |
| if (range instanceof Date) { |
| stop = new Date(range.getFullYear(), range.getMonth(), range.getDate(), range.getHours()); |
| } else { |
| stop = new Date(+start + range * 1000 * 60); |
| } |
| return d3.time.minutes(Math.min(start, stop), Math.max(start, stop)); |
| }, |
| |
| /** |
| * Return all the hours between 2 dates |
| * |
| * @param Date d A date |
| * @param int|date range Number of hours in the range, or a stop date |
| * @return array An array of hours |
| */ |
| getHourDomain: function (d, range) { |
| 'use strict'; |
| |
| var start = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()); |
| var stop = null; |
| if (range instanceof Date) { |
| stop = new Date(range.getFullYear(), range.getMonth(), range.getDate(), range.getHours()); |
| } else { |
| stop = new Date(start); |
| stop.setHours(stop.getHours() + range); |
| } |
| |
| var domains = d3.time.hours(Math.min(start, stop), Math.max(start, stop)); |
| |
| // Passing from DST to standard time |
| // If there are 25 hours, let's compress the duplicate hours |
| var i = 0; |
| var total = domains.length; |
| for (i = 0; i < total; i += 1) { |
| if (i > 0 && domains[i].getHours() === domains[i - 1].getHours()) { |
| this.DSTDomain.push(domains[i].getTime()); |
| domains.splice(i, 1); |
| break; |
| } |
| } |
| |
| // d3.time.hours is returning more hours than needed when changing |
| // from DST to standard time, because there is really 2 hours between |
| // 1am and 2am! |
| if (typeof range === 'number' && domains.length > Math.abs(range)) { |
| domains.splice(domains.length - 1, 1); |
| } |
| |
| return domains; |
| }, |
| |
| /** |
| * Return all the days between 2 dates |
| * |
| * @param Date d A date |
| * @param int|date range Number of days in the range, or a stop date |
| * @return array An array of weeks |
| */ |
| getDayDomain: function (d, range) { |
| 'use strict'; |
| |
| var start = new Date(d.getFullYear(), d.getMonth(), d.getDate()); |
| var stop = null; |
| if (range instanceof Date) { |
| stop = new Date(range.getFullYear(), range.getMonth(), range.getDate()); |
| } else { |
| stop = new Date(start); |
| stop = new Date(stop.setDate(stop.getDate() + parseInt(range, 10))); |
| } |
| |
| return d3.time.days(Math.min(start, stop), Math.max(start, stop)); |
| }, |
| |
| /** |
| * Return all the weeks between 2 dates |
| * |
| * @param Date d A date |
| * @param int|date range Number of minutes in the range, or a stop date |
| * @return array An array of weeks |
| */ |
| getWeekDomain: function (d, range) { |
| 'use strict'; |
| |
| var weekStart; |
| |
| if (this.options.weekStartOnMonday === false) { |
| weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate() - d.getDay()); |
| } else { |
| if (d.getDay() === 1) { |
| weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()); |
| } else if (d.getDay() === 0) { |
| weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()); |
| weekStart.setDate(weekStart.getDate() - 6); |
| } else { |
| weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate() - d.getDay() + 1); |
| } |
| } |
| |
| var endDate = new Date(weekStart); |
| |
| var stop = range; |
| if (typeof range !== 'object') { |
| stop = new Date(endDate.setDate(endDate.getDate() + range * 7)); |
| } |
| |
| return this.options.weekStartOnMonday === true |
| ? d3.time.mondays(Math.min(weekStart, stop), Math.max(weekStart, stop)) |
| : d3.time.sundays(Math.min(weekStart, stop), Math.max(weekStart, stop)); |
| }, |
| |
| /** |
| * Return all the months between 2 dates |
| * |
| * @param Date d A date |
| * @param int|date range Number of months in the range, or a stop date |
| * @return array An array of months |
| */ |
| getMonthDomain: function (d, range) { |
| 'use strict'; |
| |
| var start = new Date(d.getFullYear(), d.getMonth()); |
| var stop = null; |
| if (range instanceof Date) { |
| stop = new Date(range.getFullYear(), range.getMonth()); |
| } else { |
| stop = new Date(start); |
| stop = stop.setMonth(stop.getMonth() + range); |
| } |
| |
| return d3.time.months(Math.min(start, stop), Math.max(start, stop)); |
| }, |
| |
| /** |
| * Return all the years between 2 dates |
| * |
| * @param Date d date A date |
| * @param int|date range Number of minutes in the range, or a stop date |
| * @return array An array of hours |
| */ |
| getYearDomain: function (d, range) { |
| 'use strict'; |
| |
| var start = new Date(d.getFullYear(), 0); |
| var stop = null; |
| if (range instanceof Date) { |
| stop = new Date(range.getFullYear(), 0); |
| } else { |
| stop = new Date(d.getFullYear() + range, 0); |
| } |
| |
| return d3.time.years(Math.min(start, stop), Math.max(start, stop)); |
| }, |
| |
| /** |
| * Get an array of domain start dates |
| * |
| * @param int|Date date A random date included in the wanted domain |
| * @param int|Date range Number of dates to get, or a stop date |
| * @return Array of dates |
| */ |
| getDomain: function (date, range) { |
| 'use strict'; |
| |
| if (typeof date === 'number') { |
| date = new Date(date); |
| } |
| |
| if (arguments.length < 2) { |
| range = this.options.range; |
| } |
| |
| switch (this.options.domain) { |
| case 'hour': |
| var domains = this.getHourDomain(date, range); |
| |
| // Case where an hour is missing, when passing from standard time to DST |
| // Missing hour is perfectly acceptabl in subDomain, but not in domains |
| if (typeof range === 'number' && domains.length < range) { |
| if (range > 0) { |
| domains.push(this.getHourDomain(domains[domains.length - 1], 2)[1]); |
| } else { |
| domains.shift(this.getHourDomain(domains[0], -2)[0]); |
| } |
| } |
| return domains; |
| case 'day': |
| return this.getDayDomain(date, range); |
| case 'week': |
| return this.getWeekDomain(date, range); |
| case 'month': |
| return this.getMonthDomain(date, range); |
| case 'year': |
| return this.getYearDomain(date, range); |
| } |
| }, |
| |
| /* jshint maxcomplexity: false */ |
| getSubDomain: function (date) { |
| 'use strict'; |
| |
| if (typeof date === 'number') { |
| date = new Date(date); |
| } |
| |
| var parent = this; |
| |
| /** |
| * @return int |
| */ |
| var computeDaySubDomainSize = function (date, domain) { |
| switch (domain) { |
| case 'year': |
| return parent.getDayCountInYear(date); |
| case 'month': |
| return parent.getDayCountInMonth(date); |
| case 'week': |
| return 7; |
| } |
| }; |
| |
| /** |
| * @return int |
| */ |
| var computeMinSubDomainSize = function (date, domain) { |
| switch (domain) { |
| case 'hour': |
| return 60; |
| case 'day': |
| return 60 * 24; |
| case 'week': |
| return 60 * 24 * 7; |
| } |
| }; |
| |
| /** |
| * @return int |
| */ |
| var computeHourSubDomainSize = function (date, domain) { |
| switch (domain) { |
| case 'day': |
| return 24; |
| case 'week': |
| return 168; |
| case 'month': |
| return parent.getDayCountInMonth(date) * 24; |
| } |
| }; |
| |
| /** |
| * @return int |
| */ |
| var computeWeekSubDomainSize = function (date, domain) { |
| if (domain === 'month') { |
| var endOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0); |
| var endWeekNb = parent.getWeekNumber(endOfMonth); |
| var startWeekNb = parent.getWeekNumber(new Date(date.getFullYear(), date.getMonth())); |
| |
| if (startWeekNb > endWeekNb) { |
| startWeekNb = 0; |
| endWeekNb += 1; |
| } |
| |
| return endWeekNb - startWeekNb + 1; |
| } else if (domain === 'year') { |
| return parent.getWeekNumber(new Date(date.getFullYear(), 11, 31)); |
| } |
| }; |
| |
| switch (this.options.subDomain) { |
| case 'x_min': |
| case 'min': |
| return this.getMinuteDomain(date, computeMinSubDomainSize(date, this.options.domain)); |
| case 'x_hour': |
| case 'hour': |
| return this.getHourDomain(date, computeHourSubDomainSize(date, this.options.domain)); |
| case 'x_day': |
| case 'day': |
| return this.getDayDomain(date, computeDaySubDomainSize(date, this.options.domain)); |
| case 'x_week': |
| case 'week': |
| return this.getWeekDomain(date, computeWeekSubDomainSize(date, this.options.domain)); |
| case 'x_month': |
| case 'month': |
| return this.getMonthDomain(date, 12); |
| } |
| }, |
| |
| /** |
| * Get the n-th next domain after the calendar newest (rightmost) domain |
| * @param int n |
| * @return Date The start date of the wanted domain |
| */ |
| getNextDomain: function (n) { |
| 'use strict'; |
| |
| if (arguments.length === 0) { |
| n = 1; |
| } |
| return this.getDomain(this.jumpDate(this.getDomainKeys().pop(), n, this.options.domain), 1)[0]; |
| }, |
| |
| /** |
| * Get the n-th domain before the calendar oldest (leftmost) domain |
| * @param int n |
| * @return Date The start date of the wanted domain |
| */ |
| getPreviousDomain: function (n) { |
| 'use strict'; |
| |
| if (arguments.length === 0) { |
| n = 1; |
| } |
| return this.getDomain( |
| this.jumpDate(this.getDomainKeys().shift(), -n, this.options.domain), |
| 1, |
| )[0]; |
| }, |
| |
| // =========================================================================// |
| // DATAS // |
| // =========================================================================// |
| |
| /** |
| * Fetch and interpret data from the datasource |
| * |
| * @param string|object source |
| * @param Date startDate |
| * @param Date endDate |
| * @param function callback |
| * @param function|boolean afterLoad function used to convert the data into a json object. Use true to use the afterLoad callback |
| * @param updateMode |
| * |
| * @return mixed |
| * - True if there are no data to load |
| * - False if data are loaded asynchronously |
| */ |
| getDatas: function (source, startDate, endDate, callback, afterLoad, updateMode) { |
| 'use strict'; |
| |
| var self = this; |
| if (arguments.length < 5) { |
| afterLoad = true; |
| } |
| if (arguments.length < 6) { |
| updateMode = this.APPEND_ON_UPDATE; |
| } |
| var _callback = function (error, data) { |
| if (afterLoad !== false) { |
| if (typeof afterLoad === 'function') { |
| data = afterLoad(data); |
| } else if (typeof self.options.afterLoadData === 'function') { |
| data = self.options.afterLoadData(data); |
| } else { |
| console.log('Provided callback for afterLoadData is not a function.'); |
| } |
| } else if (self.options.dataType === 'csv' || self.options.dataType === 'tsv') { |
| data = this.interpretCSV(data); |
| } |
| self.parseDatas(data, updateMode, startDate, endDate); |
| if (typeof callback === 'function') { |
| callback(); |
| } |
| }; |
| |
| switch (typeof source) { |
| case 'string': |
| if (source === '') { |
| _callback(null, {}); |
| return true; |
| } else { |
| var url = this.parseURI(source, startDate, endDate); |
| var requestType = 'GET'; |
| if (self.options.dataPostPayload !== null) { |
| requestType = 'POST'; |
| } |
| var payload = null; |
| if (self.options.dataPostPayload !== null) { |
| payload = this.parseURI(self.options.dataPostPayload, startDate, endDate); |
| } |
| |
| var xhr = null; |
| switch (this.options.dataType) { |
| case 'json': |
| xhr = d3.json(url); |
| break; |
| case 'csv': |
| xhr = d3.csv(url); |
| break; |
| case 'tsv': |
| xhr = d3.tsv(url); |
| break; |
| case 'txt': |
| xhr = d3.text(url, 'text/plain'); |
| break; |
| } |
| |
| // jshint maxdepth:5 |
| if (self.options.dataRequestHeaders !== null) { |
| for (var header in self.options.dataRequestHeaders) { |
| if (self.options.dataRequestHeaders.hasOwnProperty(header)) { |
| xhr.header(header, self.options.dataRequestHeaders[header]); |
| } |
| } |
| } |
| |
| xhr.send(requestType, payload, _callback); |
| } |
| return false; |
| case 'object': |
| if (source === Object(source)) { |
| _callback(null, source); |
| return false; |
| } |
| /* falls through */ |
| default: |
| _callback(null, {}); |
| return true; |
| } |
| }, |
| |
| /** |
| * Populate the calendar internal data |
| * |
| * @param object data |
| * @param constant updateMode |
| * @param Date startDate |
| * @param Date endDate |
| * |
| * @return void |
| */ |
| parseDatas: function (data, updateMode, startDate, endDate) { |
| 'use strict'; |
| |
| if (updateMode === this.RESET_ALL_ON_UPDATE) { |
| this._domains.forEach(function (key, value) { |
| value.forEach(function (element, index, array) { |
| array[index].v = null; |
| }); |
| }); |
| } |
| |
| var temp = {}; |
| |
| var extractTime = function (d) { |
| return d.t; |
| }; |
| |
| /*jshint forin:false */ |
| for (var d in data) { |
| var date = new Date(d * 1000); |
| var domainUnit = this.getDomain(date)[0].getTime(); |
| // The current data belongs to a domain that was compressed |
| // Compress the data for the two duplicate hours into the same hour |
| if (this.DSTDomain.indexOf(domainUnit) >= 0) { |
| // Re-assign all data to the first or the second duplicate hours |
| // depending on which is visible |
| if (this._domains.has(domainUnit - 3600 * 1000)) { |
| domainUnit -= 3600 * 1000; |
| } |
| } |
| |
| // Skip if data is not relevant to current domain |
| if ( |
| isNaN(d) || |
| !data.hasOwnProperty(d) || |
| !this._domains.has(domainUnit) || |
| !(domainUnit >= +startDate && domainUnit < +endDate) |
| ) { |
| continue; |
| } |
| |
| var subDomainsData = this._domains.get(domainUnit); |
| |
| if (!temp.hasOwnProperty(domainUnit)) { |
| temp[domainUnit] = subDomainsData.map(extractTime); |
| } |
| |
| var index = temp[domainUnit].indexOf( |
| this._domainType[this.options.subDomain].extractUnit(date), |
| ); |
| |
| if (updateMode === this.RESET_SINGLE_ON_UPDATE) { |
| subDomainsData[index].v = data[d]; |
| } else { |
| if (!isNaN(subDomainsData[index].v)) { |
| subDomainsData[index].v += data[d]; |
| } else { |
| subDomainsData[index].v = data[d]; |
| } |
| } |
| } |
| }, |
| |
| parseURI: function (str, startDate, endDate) { |
| 'use strict'; |
| |
| // Use a timestamp in seconds |
| str = str.replace(/\{\{t:start\}\}/g, startDate.getTime() / 1000); |
| str = str.replace(/\{\{t:end\}\}/g, endDate.getTime() / 1000); |
| |
| // Use a string date, following the ISO-8601 |
| str = str.replace(/\{\{d:start\}\}/g, startDate.toISOString()); |
| str = str.replace(/\{\{d:end\}\}/g, endDate.toISOString()); |
| |
| return str; |
| }, |
| |
| interpretCSV: function (data) { |
| 'use strict'; |
| |
| var d = {}; |
| var keys = Object.keys(data[0]); |
| var i, total; |
| for (i = 0, total = data.length; i < total; i += 1) { |
| d[data[i][keys[0]]] = +data[i][keys[1]]; |
| } |
| return d; |
| }, |
| |
| /** |
| * Handle the calendar layout and dimension |
| * |
| * Expand and shrink the container depending on its children dimension |
| * Also rearrange the children position depending on their dimension, |
| * and the legend position |
| * |
| * @return void |
| */ |
| resize: function () { |
| 'use strict'; |
| |
| var parent = this; |
| var options = parent.options; |
| var legendWidth = options.displayLegend |
| ? parent.Legend.getDim('width') + options.legendMargin[1] + options.legendMargin[3] |
| : 0; |
| var legendHeight = options.displayLegend |
| ? parent.Legend.getDim('height') + options.legendMargin[0] + options.legendMargin[2] |
| : 0; |
| |
| var graphWidth = parent.graphDim.width - options.domainGutter - options.cellPadding; |
| var graphHeight = parent.graphDim.height - options.domainGutter - options.cellPadding; |
| |
| this.root |
| .transition() |
| .duration(options.animationDuration) |
| .attr('width', function () { |
| if ( |
| options.legendVerticalPosition === 'middle' || |
| options.legendVerticalPosition === 'center' |
| ) { |
| return graphWidth + legendWidth; |
| } |
| return Math.max(graphWidth, legendWidth); |
| }) |
| .attr('height', function () { |
| if ( |
| options.legendVerticalPosition === 'middle' || |
| options.legendVerticalPosition === 'center' |
| ) { |
| return Math.max(graphHeight, legendHeight); |
| } |
| return graphHeight + legendHeight; |
| }); |
| |
| this.root |
| .select('.graph') |
| .transition() |
| .duration(options.animationDuration) |
| .attr('y', function () { |
| if (options.legendVerticalPosition === 'top') { |
| return legendHeight; |
| } |
| return 0; |
| }) |
| .attr('x', function () { |
| if ( |
| (options.legendVerticalPosition === 'middle' || |
| options.legendVerticalPosition === 'center') && |
| options.legendHorizontalPosition === 'left' |
| ) { |
| return legendWidth; |
| } |
| return 0; |
| }); |
| }, |
| |
| // =========================================================================// |
| // PUBLIC API // |
| // =========================================================================// |
| |
| /** |
| * Shift the calendar forward |
| */ |
| next: function (n) { |
| 'use strict'; |
| |
| if (arguments.length === 0) { |
| n = 1; |
| } |
| return this.loadNextDomain(n); |
| }, |
| |
| /** |
| * Shift the calendar backward |
| */ |
| previous: function (n) { |
| 'use strict'; |
| |
| if (arguments.length === 0) { |
| n = 1; |
| } |
| return this.loadPreviousDomain(n); |
| }, |
| |
| /** |
| * Jump directly to a specific date |
| * |
| * JumpTo will scroll the calendar until the wanted domain with the specified |
| * date is visible. Unless you set reset to true, the wanted domain |
| * will not necessarily be the first (leftmost) domain of the calendar. |
| * |
| * @param Date date Jump to the domain containing that date |
| * @param bool reset Whether the wanted domain should be the first domain of the calendar |
| * @param bool True of the calendar was scrolled |
| */ |
| jumpTo: function (date, reset) { |
| 'use strict'; |
| |
| if (arguments.length < 2) { |
| reset = false; |
| } |
| var domains = this.getDomainKeys(); |
| var firstDomain = domains[0]; |
| var lastDomain = domains[domains.length - 1]; |
| |
| if (date < firstDomain) { |
| return this.loadPreviousDomain(this.getDomain(firstDomain, date).length); |
| } else { |
| if (reset) { |
| return this.loadNextDomain(this.getDomain(firstDomain, date).length); |
| } |
| |
| if (date > lastDomain) { |
| return this.loadNextDomain(this.getDomain(lastDomain, date).length); |
| } |
| } |
| |
| return false; |
| }, |
| |
| /** |
| * Navigate back to the start date |
| * |
| * @since 3.3.8 |
| * @return void |
| */ |
| rewind: function () { |
| 'use strict'; |
| |
| this.jumpTo(this.options.start, true); |
| }, |
| |
| /** |
| * Update the calendar with new data |
| * |
| * @param object|string dataSource The calendar's datasource, same type as this.options.data |
| * @param boolean|function afterLoad Whether to execute afterLoad() on the data. Pass directly a function |
| * if you don't want to use the afterLoad() callback |
| */ |
| update: function (dataSource, afterLoad, updateMode) { |
| 'use strict'; |
| |
| if (arguments.length === 0) { |
| dataSource = this.options.data; |
| } |
| if (arguments.length < 2) { |
| afterLoad = true; |
| } |
| if (arguments.length < 3) { |
| updateMode = this.RESET_ALL_ON_UPDATE; |
| } |
| |
| var domains = this.getDomainKeys(); |
| var self = this; |
| this.getDatas( |
| dataSource, |
| new Date(domains[0]), |
| this.getSubDomain(domains[domains.length - 1]).pop(), |
| function () { |
| self.fill(); |
| self.afterUpdate(); |
| }, |
| afterLoad, |
| updateMode, |
| ); |
| }, |
| |
| /** |
| * Set the legend |
| * |
| * @param array legend an array of integer, representing the different threshold value |
| * @param array colorRange an array of 2 hex colors, for the minimum and maximum colors |
| */ |
| setLegend: function () { |
| 'use strict'; |
| |
| var oldLegend = this.options.legend.slice(0); |
| if (arguments.length >= 1 && Array.isArray(arguments[0])) { |
| this.options.legend = arguments[0]; |
| } |
| if (arguments.length >= 2) { |
| if (Array.isArray(arguments[1]) && arguments[1].length >= 2) { |
| this.options.legendColors = [arguments[1][0], arguments[1][1]]; |
| } else { |
| this.options.legendColors = arguments[1]; |
| } |
| } |
| |
| if ( |
| (arguments.length > 0 && !arrayEquals(oldLegend, this.options.legend)) || |
| arguments.length >= 2 |
| ) { |
| this.Legend.buildColors(); |
| this.fill(); |
| } |
| |
| this.Legend.redraw(this.graphDim.width - this.options.domainGutter - this.options.cellPadding); |
| }, |
| |
| /** |
| * Remove the legend |
| * |
| * @return bool False if there is no legend to remove |
| */ |
| removeLegend: function () { |
| 'use strict'; |
| |
| if (!this.options.displayLegend) { |
| return false; |
| } |
| this.options.displayLegend = false; |
| this.Legend.remove(); |
| return true; |
| }, |
| |
| /** |
| * Display the legend |
| * |
| * @return bool False if the legend was already displayed |
| */ |
| showLegend: function () { |
| 'use strict'; |
| |
| if (this.options.displayLegend) { |
| return false; |
| } |
| this.options.displayLegend = true; |
| this.Legend.redraw(this.graphDim.width - this.options.domainGutter - this.options.cellPadding); |
| return true; |
| }, |
| |
| /** |
| * Highlight dates |
| * |
| * Add a highlight class to a set of dates |
| * |
| * @since 3.3.5 |
| * @param array Array of dates to highlight |
| * @return bool True if dates were highlighted |
| */ |
| highlight: function (args) { |
| 'use strict'; |
| |
| if ((this.options.highlight = this.expandDateSetting(args)).length > 0) { |
| this.fill(); |
| return true; |
| } |
| return false; |
| }, |
| |
| /** |
| * Destroy the calendar |
| * |
| * Usage: cal = cal.destroy(); |
| * |
| * @since 3.3.6 |
| * @param function A callback function to trigger after destroying the calendar |
| * @return null |
| */ |
| destroy: function (callback) { |
| 'use strict'; |
| |
| this.root |
| .transition() |
| .duration(this.options.animationDuration) |
| .attr('width', 0) |
| .attr('height', 0) |
| .remove() |
| .each('end', function () { |
| if (typeof callback === 'function') { |
| callback(); |
| } else if (typeof callback !== 'undefined') { |
| console.log('Provided callback for destroy() is not a function.'); |
| } |
| }); |
| |
| return null; |
| }, |
| |
| getSVG: function () { |
| 'use strict'; |
| |
| var styles = { |
| '.cal-heatmap-container': {}, |
| '.graph': {}, |
| '.graph-rect': {}, |
| 'rect.highlight': {}, |
| 'rect.now': {}, |
| 'rect.highlight-now': {}, |
| 'text.highlight': {}, |
| 'text.now': {}, |
| 'text.highlight-now': {}, |
| '.domain-background': {}, |
| '.graph-label': {}, |
| '.subdomain-text': {}, |
| '.q0': {}, |
| '.qi': {}, |
| }; |
| |
| for (var j = 1, total = this.options.legend.length + 1; j <= total; j += 1) { |
| styles['.q' + j] = {}; |
| } |
| |
| var root = this.root; |
| |
| var whitelistStyles = [ |
| // SVG specific properties |
| 'stroke', |
| 'stroke-width', |
| 'stroke-opacity', |
| 'stroke-dasharray', |
| 'stroke-dashoffset', |
| 'stroke-linecap', |
| 'stroke-miterlimit', |
| 'fill', |
| 'fill-opacity', |
| 'fill-rule', |
| 'marker', |
| 'marker-start', |
| 'marker-mid', |
| 'marker-end', |
| 'alignement-baseline', |
| 'baseline-shift', |
| 'dominant-baseline', |
| 'glyph-orientation-horizontal', |
| 'glyph-orientation-vertical', |
| 'kerning', |
| 'text-anchor', |
| 'shape-rendering', |
| |
| // Text Specific properties |
| 'text-transform', |
| 'font-family', |
| 'font', |
| 'font-size', |
| 'font-weight', |
| ]; |
| |
| var filterStyles = function (attribute, property, value) { |
| if (whitelistStyles.indexOf(property) !== -1) { |
| styles[attribute][property] = value; |
| } |
| }; |
| |
| var getElement = function (e) { |
| return root.select(e)[0][0]; |
| }; |
| |
| /* jshint forin:false */ |
| for (var element in styles) { |
| if (!styles.hasOwnProperty(element)) { |
| continue; |
| } |
| |
| var dom = getElement(element); |
| |
| if (dom === null) { |
| continue; |
| } |
| |
| // The DOM Level 2 CSS way |
| /* jshint maxdepth: false */ |
| if ('getComputedStyle' in window) { |
| var cs = getComputedStyle(dom, null); |
| if (cs.length !== 0) { |
| for (var i = 0; i < cs.length; i += 1) { |
| filterStyles(element, cs.item(i), cs.getPropertyValue(cs.item(i))); |
| } |
| |
| // Opera workaround. Opera doesn"t support `item`/`length` |
| // on CSSStyleDeclaration. |
| } else { |
| for (var k in cs) { |
| if (cs.hasOwnProperty(k)) { |
| filterStyles(element, k, cs[k]); |
| } |
| } |
| } |
| |
| // The IE way |
| } else if ('currentStyle' in dom) { |
| var css = dom.currentStyle; |
| for (var p in css) { |
| filterStyles(element, p, css[p]); |
| } |
| } |
| } |
| |
| var string = |
| '<svg xmlns="http://www.w3.org/2000/svg" ' + |
| 'xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css"><![CDATA[ '; |
| |
| for (var style in styles) { |
| string += style + ' {\n'; |
| for (var l in styles[style]) { |
| string += '\t' + l + ':' + styles[style][l] + ';\n'; |
| } |
| string += '}\n'; |
| } |
| |
| string += ']]></style>'; |
| string += new XMLSerializer().serializeToString(this.root[0][0]); |
| string += '</svg>'; |
| |
| return string; |
| }, |
| }; |
| |
| // =========================================================================// |
| // DOMAIN POSITION COMPUTATION // |
| // =========================================================================// |
| |
| /** |
| * Compute the position of a domain, relative to the calendar |
| */ |
| var DomainPosition = function () { |
| 'use strict'; |
| |
| this.positions = d3.map(); |
| }; |
| |
| DomainPosition.prototype.getPosition = function (d) { |
| 'use strict'; |
| |
| return this.positions.get(d); |
| }; |
| |
| DomainPosition.prototype.getPositionFromIndex = function (i) { |
| 'use strict'; |
| |
| var domains = this.getKeys(); |
| return this.positions.get(domains[i]); |
| }; |
| |
| DomainPosition.prototype.getLast = function () { |
| 'use strict'; |
| |
| var domains = this.getKeys(); |
| return this.positions.get(domains[domains.length - 1]); |
| }; |
| |
| DomainPosition.prototype.setPosition = function (d, dim) { |
| 'use strict'; |
| |
| this.positions.set(d, dim); |
| }; |
| |
| DomainPosition.prototype.shiftRightBy = function (exitingDomainDim) { |
| 'use strict'; |
| |
| this.positions.forEach(function (key, value) { |
| this.set(key, value - exitingDomainDim); |
| }); |
| |
| var domains = this.getKeys(); |
| this.positions.remove(domains[0]); |
| }; |
| |
| DomainPosition.prototype.shiftLeftBy = function (enteringDomainDim) { |
| 'use strict'; |
| |
| this.positions.forEach(function (key, value) { |
| this.set(key, value + enteringDomainDim); |
| }); |
| |
| var domains = this.getKeys(); |
| this.positions.remove(domains[domains.length - 1]); |
| }; |
| |
| DomainPosition.prototype.getKeys = function () { |
| 'use strict'; |
| |
| return this.positions.keys().sort(function (a, b) { |
| return parseInt(a, 10) - parseInt(b, 10); |
| }); |
| }; |
| |
| // =========================================================================// |
| // LEGEND // |
| // =========================================================================// |
| |
| var Legend = function (calendar) { |
| 'use strict'; |
| |
| this.calendar = calendar; |
| this.computeDim(); |
| |
| if (calendar.options.legendColors !== null) { |
| this.buildColors(); |
| } |
| }; |
| |
| Legend.prototype.computeDim = function () { |
| 'use strict'; |
| |
| var options = this.calendar.options; // Shorter accessor for variable name mangling when minifying |
| this.dim = { |
| width: |
| options.legendCellSize * (options.legend.length + 1) + |
| options.legendCellPadding * options.legend.length, |
| height: options.legendCellSize, |
| }; |
| }; |
| |
| Legend.prototype.remove = function () { |
| 'use strict'; |
| |
| this.calendar.root.select('.graph-legend').remove(); |
| this.calendar.resize(); |
| }; |
| |
| Legend.prototype.redraw = function (width) { |
| 'use strict'; |
| |
| if (!this.calendar.options.displayLegend) { |
| return false; |
| } |
| |
| var parent = this; |
| var calendar = this.calendar; |
| var legend = calendar.root; |
| var legendItem; |
| var options = calendar.options; // Shorter accessor for variable name mangling when minifying |
| |
| this.computeDim(); |
| |
| var _legend = options.legend.slice(0); |
| _legend.push(_legend[_legend.length - 1] + 1); |
| |
| var legendElement = calendar.root.select('.graph-legend'); |
| if (legendElement[0][0] !== null) { |
| legend = legendElement; |
| legendItem = legend.select('g').selectAll('rect').data(_legend); |
| } else { |
| // Creating the new legend DOM if it doesn't already exist |
| legend = |
| options.legendVerticalPosition === 'top' |
| ? legend.insert('svg', '.graph') |
| : legend.append('svg'); |
| |
| legend.attr('x', getLegendXPosition()).attr('y', getLegendYPosition()); |
| |
| legendItem = legend |
| .attr('class', 'graph-legend') |
| .attr('height', parent.getDim('height')) |
| .attr('width', parent.getDim('width')) |
| .append('g') |
| .selectAll() |
| .data(_legend); |
| } |
| |
| legendItem |
| .enter() |
| .append('rect') |
| .call(legendCellLayout) |
| .attr('class', function (d) { |
| return calendar.Legend.getClass(d, calendar.legendScale === null); |
| }) |
| .attr('fill-opacity', 0) |
| .call(function (selection) { |
| if ( |
| calendar.legendScale !== null && |
| options.legendColors !== null && |
| options.legendColors.hasOwnProperty('base') |
| ) { |
| selection.attr('fill', options.legendColors.base); |
| } |
| }) |
| .append('title'); |
| |
| legendItem |
| .exit() |
| .transition() |
| .duration(options.animationDuration) |
| .attr('fill-opacity', 0) |
| .remove(); |
| |
| legendItem |
| .transition() |
| .delay(function (d, i) { |
| return (options.animationDuration * i) / 10; |
| }) |
| .call(legendCellLayout) |
| .attr('fill-opacity', 1) |
| .call(function (element) { |
| element.attr('fill', function (d, i) { |
| if (calendar.legendScale === null) { |
| return ''; |
| } |
| |
| if (i === 0) { |
| return calendar.legendScale(d - 1); |
| } |
| return calendar.legendScale(options.legend[i - 1]); |
| }); |
| |
| element.attr('class', function (d) { |
| return calendar.Legend.getClass(d, calendar.legendScale === null); |
| }); |
| }); |
| |
| function legendCellLayout(selection) { |
| selection |
| .attr('width', options.legendCellSize) |
| .attr('height', options.legendCellSize) |
| .attr('rx', options.legendCellRadius) |
| .attr('ry', options.legendCellRadius) |
| .attr('x', function (d, i) { |
| return i * (options.legendCellSize + options.legendCellPadding); |
| }); |
| } |
| |
| legendItem.select('title').text(function (d, i) { |
| if (i === 0) { |
| return calendar.formatStringWithObject(options.legendTitleFormat.lower, { |
| min: options.legend[i], |
| name: options.itemName[1], |
| }); |
| } else if (i === _legend.length - 1) { |
| return calendar.formatStringWithObject(options.legendTitleFormat.upper, { |
| max: options.legend[i - 1], |
| name: options.itemName[1], |
| }); |
| } else { |
| return calendar.formatStringWithObject(options.legendTitleFormat.inner, { |
| down: options.legend[i - 1], |
| up: options.legend[i], |
| name: options.itemName[1], |
| }); |
| } |
| }); |
| legendItem |
| .on('mouseover', function (d) { |
| calendar.legendTip.show(d, this); |
| }) |
| .on('mouseout', function () { |
| calendar.legendTip.hide(); |
| }); |
| |
| legend |
| .transition() |
| .duration(options.animationDuration) |
| .attr('x', getLegendXPosition()) |
| .attr('y', getLegendYPosition()) |
| .attr('width', parent.getDim('width')) |
| .attr('height', parent.getDim('height')); |
| |
| legend |
| .select('g') |
| .transition() |
| .duration(options.animationDuration) |
| .attr('transform', function () { |
| if (options.legendOrientation === 'vertical') { |
| return 'rotate(90 ' + options.legendCellSize / 2 + ' ' + options.legendCellSize / 2 + ')'; |
| } |
| return ''; |
| }); |
| |
| function getLegendXPosition() { |
| switch (options.legendHorizontalPosition) { |
| case 'right': |
| if ( |
| options.legendVerticalPosition === 'center' || |
| options.legendVerticalPosition === 'middle' |
| ) { |
| return width + options.legendMargin[3]; |
| } |
| return width - parent.getDim('width') - options.legendMargin[1]; |
| case 'middle': |
| case 'center': |
| return Math.round(width / 2 - parent.getDim('width') / 2); |
| default: |
| return options.legendMargin[3]; |
| } |
| } |
| |
| function getLegendYPosition() { |
| if (options.legendVerticalPosition === 'bottom') { |
| return ( |
| calendar.graphDim.height + |
| options.legendMargin[0] - |
| options.domainGutter - |
| options.cellPadding |
| ); |
| } |
| return options.legendMargin[0]; |
| } |
| |
| calendar.resize(); |
| }; |
| |
| /** |
| * Return the dimension of the legend |
| * |
| * Takes into account rotation |
| * |
| * @param string axis Width or height |
| * @return int height or width in pixels |
| */ |
| Legend.prototype.getDim = function (axis) { |
| 'use strict'; |
| |
| var isHorizontal = this.calendar.options.legendOrientation === 'horizontal'; |
| |
| switch (axis) { |
| case 'width': |
| return this.dim[isHorizontal ? 'width' : 'height']; |
| case 'height': |
| return this.dim[isHorizontal ? 'height' : 'width']; |
| } |
| }; |
| |
| Legend.prototype.buildColors = function () { |
| 'use strict'; |
| |
| var options = this.calendar.options; // Shorter accessor for variable name mangling when minifying |
| |
| if (options.legendColors === null) { |
| this.calendar.legendScale = null; |
| return false; |
| } |
| |
| var _colorRange = []; |
| |
| if (Array.isArray(options.legendColors)) { |
| _colorRange = options.legendColors; |
| } else if ( |
| options.legendColors.hasOwnProperty('min') && |
| options.legendColors.hasOwnProperty('max') |
| ) { |
| _colorRange = [options.legendColors.min, options.legendColors.max]; |
| } else { |
| options.legendColors = null; |
| return false; |
| } |
| |
| var _legend = options.legend.slice(0); |
| |
| if (_legend[0] > 0) { |
| _legend.unshift(0); |
| } else if (_legend[0] <= 0) { |
| // Let's guess the leftmost value, it we have to add one |
| _legend.unshift(_legend[0] - (_legend[_legend.length - 1] - _legend[0]) / _legend.length); |
| } |
| var colorScale; |
| if (options.legendColors.hasOwnProperty('colorScale')) { |
| colorScale = options.legendColors.colorScale; |
| } else { |
| colorScale = d3.scale |
| .linear() |
| .range(_colorRange) |
| .interpolate(d3.interpolateHcl) |
| .domain([d3.min(_legend), d3.max(_legend)]); |
| } |
| |
| var legendColors = _legend.map(function (element) { |
| return colorScale(element); |
| }); |
| this.calendar.legendScale = d3.scale.threshold().domain(options.legend).range(legendColors); |
| |
| return true; |
| }; |
| |
| /** |
| * Return the classname on the legend for the specified value |
| * |
| * @param integer n Value associated to a date |
| * @param bool withCssClass Whether to display the css class used to style the cell. |
| * Disabling will allow styling directly via html fill attribute |
| * |
| * @return string Classname according to the legend |
| */ |
| Legend.prototype.getClass = function (n, withCssClass) { |
| 'use strict'; |
| |
| if (n === null || isNaN(n)) { |
| return ''; |
| } |
| |
| var index = [this.calendar.options.legend.length + 1]; |
| |
| for (var i = 0, total = this.calendar.options.legend.length - 1; i <= total; i += 1) { |
| if (this.calendar.options.legend[0] > 0 && n < 0) { |
| index = ['1', 'i']; |
| break; |
| } |
| |
| if (n <= this.calendar.options.legend[i]) { |
| index = [i + 1]; |
| break; |
| } |
| } |
| |
| if (n === 0) { |
| index.push(0); |
| } |
| |
| index.unshift(''); |
| return (index.join(' r') + (withCssClass ? index.join(' q') : '')).trim(); |
| }; |
| |
| /** |
| * #source http://stackoverflow.com/a/383245/805649 |
| */ |
| function mergeRecursive(obj1, obj2) { |
| 'use strict'; |
| |
| /*jshint forin:false */ |
| for (var p in obj2) { |
| try { |
| // Property in destination object set; update its value. |
| if (obj2[p].constructor === Object) { |
| obj1[p] = mergeRecursive(obj1[p], obj2[p]); |
| } else { |
| obj1[p] = obj2[p]; |
| } |
| } catch (e) { |
| // Property in destination object not set; create it and set its value. |
| obj1[p] = obj2[p]; |
| } |
| } |
| |
| return obj1; |
| } |
| |
| /** |
| * Check if 2 arrays are equals |
| * |
| * @link http://stackoverflow.com/a/14853974/805649 |
| * @param array array the array to compare to |
| * @return bool true of the 2 arrays are equals |
| */ |
| function arrayEquals(arrayA, arrayB) { |
| 'use strict'; |
| |
| // if the other array is a falsy value, return |
| if (!arrayB || !arrayA) { |
| return false; |
| } |
| |
| // compare lengths - can save a lot of time |
| if (arrayA.length !== arrayB.length) { |
| return false; |
| } |
| |
| for (var i = 0; i < arrayA.length; i += 1) { |
| // Check if we have nested arrays |
| if (arrayA[i] instanceof Array && arrayB[i] instanceof Array) { |
| // recurse into the nested arrays |
| if (!arrayEquals(arrayA[i], arrayB[i])) { |
| return false; |
| } |
| } else if (arrayA[i] !== arrayB[i]) { |
| // Warning - two different object instances will never be equal: {x:20} != {x:20} |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| export default CalHeatMap; |