3485 lines
93 KiB
JavaScript
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 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"];
|
|
|
|
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;
|
|
}
|