blob: a8da0b374afb112f5937fca00259ce13ce16e145 [file] [log] [blame]
// [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;