MISP/app/webroot/js/cal-heatmap.js

3485 lines
93 KiB
JavaScript

/*! cal-heatmap v3.6.0 (Sun Apr 24 2016 19:19:35)
* ---------------------------------------------
* Cal-Heatmap is a javascript module to create calendar heatmap to visualize time series data
* https://github.com/wa0x6e/cal-heatmap
* Licensed under the MIT license
* Copyright 2014 Wan Qi Chen
*/
var d3 = typeof require === "function" ? require("d3") : window.d3;
var CalHeatMap = function() {
"use strict";
var self = this;
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],
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,
// 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"
// }
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 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()-1;
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.tooltip = d3.select(self.options.itemSelector)
.attr("style", function() {
var current = d3.select(self.options.itemSelector).attr("style");
return (current !== null ? current : "") + "position:relative;";
})
.append("div")
.attr("class", "ch-tooltip")
;
self.root.attr("x", 0).attr("y", 0).append("svg").attr("class", "graph");
self.Legend = new Legend(self);
if (self.options.paintOnLoad) {
_initCalendar();
}
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) {
var domainNode = this.parentNode.parentNode;
self.tooltip
.html(self.getSubDomainTitle(d))
.attr("style", "display: block;")
;
var tooltipPositionX = self.positionSubDomainX(d.t) - self.tooltip[0][0].offsetWidth/2 + options.cellSize/2;
var tooltipPositionY = self.positionSubDomainY(d.t) - self.tooltip[0][0].offsetHeight - options.cellSize/2;
// Offset by the domain position
tooltipPositionX += parseInt(domainNode.getAttribute("x"), 10);
tooltipPositionY += parseInt(domainNode.getAttribute("y"), 10);
// Offset by the calendar position (when legend is left/top)
tooltipPositionX += parseInt(self.root.select(".graph").attr("x"), 10);
tooltipPositionY += parseInt(self.root.select(".graph").attr("y"), 10);
// Offset by the inside domain position (when label is left/top)
tooltipPositionX += parseInt(domainNode.parentNode.getAttribute("x"), 10);
tooltipPositionY += parseInt(domainNode.parentNode.getAttribute("y"), 10);
self.tooltip.attr("style",
"display: block; " +
"left: " + tooltipPositionX + "px; " +
"top: " + tooltipPositionY + "px;")
;
});
selection.on("mouseout", function() {
self.tooltip
.attr("style", "display:none")
.html("");
});
}
})
;
// 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 exist");
}
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"];
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)
;
},
// =========================================================================//
// 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);
}
}
},
// =========================================================================//
// 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.options.subDomainTitleFormat.empty).format({
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.options.subDomainTitleFormat.filled).format({
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++) {
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++) {
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++;
}
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(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({});
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);
}
switch(this.options.dataType) {
case "json":
d3.json(url, _callback).send(requestType, payload);
break;
case "csv":
d3.csv(url, _callback).send(requestType, payload);
break;
case "tsv":
d3.tsv(url, _callback).send(requestType, payload);
break;
case "txt":
d3.text(url, "text/plain", _callback).send(requestType, payload);
break;
}
}
return false;
case "object":
if (source === Object(source)) {
_callback(source);
return false;
}
/* falls through */
default:
_callback({});
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++) {
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 < 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();
},
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++) {
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++) {
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("x", function(d, i) {
return i * (options.legendCellSize + options.legendCellPadding);
})
;
}
legendItem.select("title").text(function(d, i) {
if (i === 0) {
return (options.legendTitleFormat.lower).format({
min: options.legend[i],
name: options.itemName[1]
});
} else if (i === _legend.length-1) {
return (options.legendTitleFormat.upper).format({
max: options.legend[i-1],
name: options.itemName[1]
});
} else {
return (options.legendTitleFormat.inner).format({
down: options.legend[i-1],
up: options.legend[i],
name: options.itemName[1]
});
}
})
;
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 = 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++) {
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();
};
/**
* Sprintf like function
* @source http://stackoverflow.com/a/4795914/805649
* @return String
*/
String.prototype.format = function () {
"use strict";
var formatted = this;
for (var prop in arguments[0]) {
if (arguments[0].hasOwnProperty(prop)) {
var regexp = new RegExp("\\{" + prop + "\\}", "gi");
formatted = formatted.replace(regexp, arguments[0][prop]);
}
}
return formatted;
};
/**
* #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++) {
// 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;
}
/**
* AMD Loader
*/
if (typeof define === "function" && define.amd) {
define(["d3"], function() {
"use strict";
return CalHeatMap;
});
} else if (typeof module === "object" && module.exports) {
module.exports = CalHeatMap;
} else {
window.CalHeatMap = CalHeatMap;
}