You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ma...@apache.org on 2018/04/13 00:16:48 UTC

[incubator-superset] branch master updated: Improve the calendar heatmap (#4800)

This is an automated email from the ASF dual-hosted git repository.

maximebeauchemin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 6fd4ff4  Improve the calendar heatmap (#4800)
6fd4ff4 is described below

commit 6fd4ff45ea7836195f8209ea43e5666b19d1b3d0
Author: Maxime Beauchemin <ma...@gmail.com>
AuthorDate: Thu Apr 12 17:16:45 2018 -0700

    Improve the calendar heatmap (#4800)
    
    * Improve xAxis ticks, thinner bottom margin (#4756)
    
    * Improve xAxis ticks, thinner bottom margin
    
    * Moving utils folder
    
    * Add isTruthy
    
    * Addressing comments
    
    * Set cell_padding to 0
    
    * merging db migrations
---
 superset/assets/javascripts/chart/Chart.jsx        |    4 +
 .../assets/javascripts/explore/stores/controls.jsx |   57 +-
 .../assets/javascripts/explore/stores/visTypes.js  |   25 +-
 superset/assets/javascripts/modules/colors.js      |   36 +
 superset/assets/package.json                       |    1 -
 superset/assets/vendor/cal-heatmap/cal-heatmap.css |  145 +
 superset/assets/vendor/cal-heatmap/cal-heatmap.js  | 3514 ++++++++++++++++++++
 superset/assets/visualizations/cal_heatmap.css     |    7 +-
 superset/assets/visualizations/cal_heatmap.js      |   83 +-
 superset/assets/yarn.lock                          |    8 +-
 superset/migrations/versions/5ccf602336a0_.py      |   22 +
 .../bf706ae5eb46_cal_heatmap_metric_to_metrics.py  |   56 +
 superset/migrations/versions/c9495751e314_.py      |   22 +
 superset/migrations/versions/f231d82b9b26_.py      |   13 +-
 superset/viz.py                                    |   21 +-
 15 files changed, 3969 insertions(+), 45 deletions(-)

diff --git a/superset/assets/javascripts/chart/Chart.jsx b/superset/assets/javascripts/chart/Chart.jsx
index fb9884b..defe6e4 100644
--- a/superset/assets/javascripts/chart/Chart.jsx
+++ b/superset/assets/javascripts/chart/Chart.jsx
@@ -153,6 +153,10 @@ class Chart extends React.PureComponent {
     this.props.actions.chartRenderingFailed(e, this.props.chartKey);
   }
 
+  verboseMetricName(metric) {
+    return this.props.datasource.verbose_map[metric] || metric;
+  }
+
   render_template(s) {
     const context = {
       width: this.width(),
diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx
index 2d9f964..06edf06 100644
--- a/superset/assets/javascripts/explore/stores/controls.jsx
+++ b/superset/assets/javascripts/explore/stores/controls.jsx
@@ -230,6 +230,7 @@ export const controls = {
     default: colorPrimary,
     renderTrigger: true,
   },
+
   legend_position: {
     label: t('Legend Position'),
     description: t('Choose the position of the legend'),
@@ -324,11 +325,17 @@ export const controls = {
     label: t('Linear Color Scheme'),
     choices: [
       ['fire', 'fire'],
-      ['blue_white_yellow', 'blue/white/yellow'],
       ['white_black', 'white/black'],
       ['black_white', 'black/white'],
       ['dark_blue', 'light/dark blue'],
       ['pink_grey', 'pink/white/grey'],
+      ['greens', 'greens'],
+      ['purples', 'purples'],
+      ['oranges', 'oranges'],
+      ['blue_white_yellow', 'blue/white/yellow'],
+      ['red_yellow_blue', 'red/yellowish/blue'],
+      ['brown_white_green', 'brown/white/green'],
+      ['purple_white_green', 'purple/white/green'],
     ],
     default: 'blue_white_yellow',
     clearable: false,
@@ -1006,6 +1013,46 @@ export const controls = {
     'relative to the time granularity selected'),
   },
 
+  cell_size: {
+    type: 'TextControl',
+    isInt: true,
+    default: 10,
+    validators: [v.integer],
+    renderTrigger: true,
+    label: t('Cell Size'),
+    description: t('The size of the square cell, in pixels'),
+  },
+
+  cell_padding: {
+    type: 'TextControl',
+    isInt: true,
+    validators: [v.integer],
+    renderTrigger: true,
+    default: 0,
+    label: t('Cell Padding'),
+    description: t('The distance between cells, in pixels'),
+  },
+
+  cell_radius: {
+    type: 'TextControl',
+    isInt: true,
+    validators: [v.integer],
+    renderTrigger: true,
+    default: 0,
+    label: t('Cell Radius'),
+    description: t('The pixel radius'),
+  },
+
+  steps: {
+    type: 'TextControl',
+    isInt: true,
+    validators: [v.integer],
+    renderTrigger: true,
+    default: 10,
+    label: t('Color Steps'),
+    description: t('The number color "steps"'),
+  },
+
   grid_size: {
     type: 'TextControl',
     label: t('Grid Size'),
@@ -1462,6 +1509,14 @@ export const controls = {
     description: t('Whether to display the numerical values within the cells'),
   },
 
+  show_metric_name: {
+    type: 'CheckboxControl',
+    label: t('Show Metric Names'),
+    renderTrigger: true,
+    default: true,
+    description: t('Whether to display the metric name as a title'),
+  },
+
   x_axis_showminmax: {
     type: 'CheckboxControl',
     label: t('X bounds'),
diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js
index a8819d3..473ee58 100644
--- a/superset/assets/javascripts/explore/stores/visTypes.js
+++ b/superset/assets/javascripts/explore/stores/visTypes.js
@@ -977,17 +977,34 @@ export const visTypes = {
         label: t('Query'),
         expanded: true,
         controlSetRows: [
-          ['metric'],
+          ['domain_granularity', 'subdomain_granularity'],
+          ['metrics'],
         ],
       },
       {
-        label: t('Options'),
+        label: t('Chart Options'),
+        expanded: true,
         controlSetRows: [
-          ['domain_granularity'],
-          ['subdomain_granularity'],
+          ['linear_color_scheme'],
+          ['cell_size', 'cell_padding'],
+          ['cell_radius', 'steps'],
+          ['y_axis_format', 'x_axis_time_format'],
+          ['show_legend', 'show_values'],
+          ['show_metric_name', null],
         ],
       },
     ],
+    controlOverrides: {
+      y_axis_format: {
+        label: t('Number Format'),
+      },
+      x_axis_time_format: {
+        label: t('Time Format'),
+      },
+      show_values: {
+        default: false,
+      },
+    },
   },
 
   box_plot: {
diff --git a/superset/assets/javascripts/modules/colors.js b/superset/assets/javascripts/modules/colors.js
index 1cd2b53..c6ce01b 100644
--- a/superset/assets/javascripts/modules/colors.js
+++ b/superset/assets/javascripts/modules/colors.js
@@ -122,6 +122,42 @@ export const spectrums = {
     '#FAFAFA',
     '#666666',
   ],
+  greens: [
+    '#ffffcc',
+    '#78c679',
+    '#006837',
+  ],
+  purples: [
+    '#f2f0f7',
+    '#9e9ac8',
+    '#54278f',
+  ],
+  oranges: [
+    '#fef0d9',
+    '#fc8d59',
+    '#b30000',
+  ],
+  red_yellow_blue: [
+    '#d7191c',
+    '#fdae61',
+    '#ffffbf',
+    '#abd9e9',
+    '#2c7bb6',
+  ],
+  brown_white_green: [
+    '#a6611a',
+    '#dfc27d',
+    '#f5f5f5',
+    '#80cdc1',
+    '#018571',
+  ],
+  purple_white_green: [
+    '#7b3294',
+    '#c2a5cf',
+    '#f7f7f7',
+    '#a6dba0',
+    '#008837',
+  ],
 };
 
 /**
diff --git a/superset/assets/package.json b/superset/assets/package.json
index 69e36ee..78545cf 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -48,7 +48,6 @@
     "bootstrap-slider": "^10.0.0",
     "brace": "^0.10.0",
     "brfs": "^1.4.3",
-    "cal-heatmap": "3.6.2",
     "classnames": "^2.2.5",
     "d3": "^3.5.17",
     "d3-cloud": "^1.2.1",
diff --git a/superset/assets/vendor/cal-heatmap/cal-heatmap.css b/superset/assets/vendor/cal-heatmap/cal-heatmap.css
new file mode 100644
index 0000000..068997c
--- /dev/null
+++ b/superset/assets/vendor/cal-heatmap/cal-heatmap.css
@@ -0,0 +1,145 @@
+/* Cal-HeatMap CSS */
+
+.cal-heatmap-container {
+  display: block;
+}
+
+.cal-heatmap-container .graph
+{
+  font-family: "Lucida Grande", Lucida, Verdana, sans-serif;
+}
+
+.cal-heatmap-container .graph-label
+{
+  fill: #999;
+  font-size: 10px
+}
+
+.cal-heatmap-container .graph, .cal-heatmap-container .graph-legend rect {
+  shape-rendering: crispedges
+}
+
+.cal-heatmap-container .graph-rect
+{
+  fill: #ededed
+}
+
+.cal-heatmap-container .graph-subdomain-group rect:hover
+{
+  stroke: #000;
+  stroke-width: 1px
+}
+
+.cal-heatmap-container .subdomain-text {
+  font-size: 8px;
+  fill: #999;
+  pointer-events: none
+}
+
+.cal-heatmap-container .hover_cursor:hover {
+  cursor: pointer
+}
+
+.cal-heatmap-container .qi {
+  background-color: #999;
+  fill: #999
+}
+
+/*
+Remove comment to apply this style to date with value equal to 0
+.q0
+{
+  background-color: #fff;
+  fill: #fff;
+  stroke: #ededed
+}
+*/
+
+.cal-heatmap-container .q1
+{
+  background-color: #dae289;
+  fill: #dae289
+}
+
+.cal-heatmap-container .q2
+{
+  background-color: #cedb9c;
+  fill: #9cc069
+}
+
+.cal-heatmap-container .q3
+{
+  background-color: #b5cf6b;
+  fill: #669d45
+}
+
+.cal-heatmap-container .q4
+{
+  background-color: #637939;
+  fill: #637939
+}
+
+.cal-heatmap-container .q5
+{
+  background-color: #3b6427;
+  fill: #3b6427
+}
+
+.cal-heatmap-container rect.highlight
+{
+  stroke:#444;
+  stroke-width:1
+}
+
+.cal-heatmap-container text.highlight
+{
+  fill: #444
+}
+
+.cal-heatmap-container rect.highlight-now
+{
+  stroke: red
+}
+
+.cal-heatmap-container text.highlight-now
+{
+  fill: red;
+  font-weight: 800
+}
+
+.cal-heatmap-container .domain-background {
+  fill: none;
+  shape-rendering: crispedges
+}
+
+.ch-tooltip {
+  padding: 10px;
+  background: #222;
+  color: #bbb;
+  font-size: 12px;
+  line-height: 1.4;
+  width: 140px;
+  position: absolute;
+  z-index: 99999;
+  text-align: center;
+  border-radius: 2px;
+  box-shadow: 2px 2px 2px rgba(0,0,0,0.2);
+  display: none;
+  box-sizing: border-box;
+}
+
+.ch-tooltip::after{
+  position: absolute;
+  width: 0;
+  height: 0;
+  border-color: transparent;
+  border-style: solid;
+  content: "";
+  padding: 0;
+  display: block;
+  bottom: -6px;
+  left: 50%;
+  margin-left: -6px;
+    border-width: 6px 6px 0;
+    border-top-color: #222;
+}
diff --git a/superset/assets/vendor/cal-heatmap/cal-heatmap.js b/superset/assets/vendor/cal-heatmap/cal-heatmap.js
new file mode 100644
index 0000000..0b00756
--- /dev/null
+++ b/superset/assets/vendor/cal-heatmap/cal-heatmap.js
@@ -0,0 +1,3514 @@
+/* Copied and altered from http://cal-heatmap.com/ , alterations around:
+ * - tuning tooltips
+ * - supporting multi-colors scales
+ * - legend format
+ * - UTC handling
+ */
+
+import d3tip from 'd3-tip';
+import '../../stylesheets/d3tip.css';
+
+var d3 = typeof require === "function" ? require("d3") : window.d3;
+
+var d3 = typeof require === "function" ? require("d3") : window.d3;
+
+var CalHeatMap = function() {
+  "use strict";
+
+  var self = this;
+  self.tip = d3tip()
+    .attr('class', 'd3-tip')
+    .direction('n')
+    .offset([-5, 0])
+    .html(d => `
+      ${self.options.timeFormatter(d.t)}: <strong>${self.options.valueFormatter(d.v)}</strong>
+    `);
+  self.legendTip = d3tip()
+    .attr('class', 'd3-tip')
+    .direction('n')
+    .offset([-5, 0])
+    .html(d => self.options.valueFormatter(d));
+
+  this.allowedDataType = ["json", "csv", "tsv", "txt"];
+
+  // Default settings
+  this.options = {
+    // selector string of the container to append the graph to
+    // Accept any string value accepted by document.querySelector or CSS3
+    // or an Element object
+    itemSelector: "#cal-heatmap",
+
+    // Whether to paint the calendar on init()
+    // Used by testsuite to reduce testing time
+    paintOnLoad: true,
+
+    // ================================================
+    // DOMAIN
+    // ================================================
+
+    // Number of domain to display on the graph
+    range: 12,
+
+    // Size of each cell, in pixel
+    cellSize: 10,
+
+    // Padding between each cell, in pixel
+    cellPadding: 2,
+
+    // For rounded subdomain rectangles, in pixels
+    cellRadius: 0,
+
+    domainGutter: 2,
+
+    domainMargin: [0, 0, 0, 0],
+
+    valueFormatter: d => d,
+
+    timeFormatter: d => d,
+
+    domain: "hour",
+
+    subDomain: "min",
+
+    // Number of columns to split the subDomains to
+    // If not null, will takes precedence over rowLimit
+    colLimit: null,
+
+    // Number of rows to split the subDomains to
+    // Will be ignored if colLimit is not null
+    rowLimit: null,
+
+    // First day of the week is Monday
+    // 0 to start the week on Sunday
+    weekStartOnMonday: true,
+
+    // Start date of the graph
+    // @default now
+    start: new Date(),
+
+    minDate: null,
+
+    maxDate: null,
+
+    // ================================================
+    // DATA
+    // ================================================
+
+    // Data source
+    // URL, where to fetch the original datas
+    data: "",
+
+    // Data type
+    // Default: json
+    dataType: this.allowedDataType[0],
+
+    // Payload sent when using POST http method
+    // Leave to null (default) for GET request
+    // Expect a string, formatted like "a=b;c=d"
+    dataPostPayload: null,
+
+    // Additional headers sent when requesting data
+    // Expect an object formatted like:
+    // { 'X-CSRF-TOKEN': 'token' }
+    dataRequestHeaders: null,
+
+    // Whether to consider missing date:value from the datasource
+    // as equal to 0, or just leave them as missing
+    considerMissingDataAsZero: false,
+
+    // Load remote data on calendar creation
+    // When false, the calendar will be left empty
+    loadOnInit: true,
+
+    // Calendar orientation
+    // false: display domains side by side
+    // true : display domains one under the other
+    verticalOrientation: false,
+
+    // Domain dynamic width/height
+    // The width on a domain depends on the number of
+    domainDynamicDimension: true,
+
+    // Domain Label properties
+    label: {
+      // valid: top, right, bottom, left
+      position: "bottom",
+
+      // Valid: left, center, right
+      // Also valid are the direct svg values: start, middle, end
+      align: "center",
+
+      // By default, there is no margin/padding around the label
+      offset: {
+        x: 0,
+        y: 0
+      },
+
+      rotate: null,
+
+      // Used only on vertical orientation
+      width: 100,
+
+      // Used only on horizontal orientation
+      height: null
+    },
+
+    // ================================================
+    // LEGEND
+    // ================================================
+
+    // Threshold for the legend
+    legend: [10, 20, 30, 40],
+
+    // Whether to display the legend
+    displayLegend: true,
+
+    legendCellSize: 10,
+
+    legendCellPadding: 2,
+
+    legendMargin: [0, 0, 0, 0],
+
+    // Legend vertical position
+    // top: place legend above calendar
+    // bottom: place legend below the calendar
+    legendVerticalPosition: "bottom",
+
+    // Legend horizontal position
+    // accepted values: left, center, right
+    legendHorizontalPosition: "left",
+
+    // Legend rotation
+    // accepted values: horizontal, vertical
+    legendOrientation: "horizontal",
+
+    // Objects holding all the heatmap different colors
+    // null to disable, and use the default css styles
+    //
+    // Examples:
+    // legendColors: {
+    //    min: "green",
+    //    max: "red",
+    //    empty: "#ffffff",
+    //    base: "grey",
+    //    overflow: "red",
+    //    colorScaler: null,
+    // }
+    legendColors: null,
+
+    // ================================================
+    // HIGHLIGHT
+    // ================================================
+
+    // List of dates to highlight
+    // Valid values:
+    // - []: don't highlight anything
+    // - "now": highlight the current date
+    // - an array of Date objects: highlight the specified dates
+    highlight: [],
+
+    // ================================================
+    // TEXT FORMATTING / i18n
+    // ================================================
+
+    // Name of the items to represent in the calendar
+    itemName: ["item", "items"],
+
+    // Formatting of the domain label
+    // @default: null, will use the formatting according to domain type
+    // Accept a string used as specifier by d3.time.format()
+    // or a function
+    //
+    // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting
+    // for accepted date formatting used by d3.time.format()
+    domainLabelFormat: null,
+
+    // Formatting of the title displayed when hovering a subDomain cell
+    subDomainTitleFormat: {
+      empty: "{date}",
+      filled: "{count} {name} {connector} {date}"
+    },
+
+    // Formatting of the {date} used in subDomainTitleFormat
+    // @default: null, will use the formatting according to subDomain type
+    // Accept a string used as specifier by d3.time.format()
+    // or a function
+    //
+    // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting
+    // for accepted date formatting used by d3.time.format()
+    subDomainDateFormat: null,
+
+    // Formatting of the text inside each subDomain cell
+    // @default: null, no text
+    // Accept a string used as specifier by d3.time.format()
+    // or a function
+    //
+    // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting
+    // for accepted date formatting used by d3.time.format()
+    subDomainTextFormat: null,
+
+    // Formatting of the title displayed when hovering a legend cell
+    legendTitleFormat: {
+      lower: "less than {min} {name}",
+      inner: "between {down} and {up} {name}",
+      upper: "more than {max} {name}"
+    },
+
+    // Animation duration, in ms
+    animationDuration: 500,
+
+    nextSelector: false,
+
+    previousSelector: false,
+
+    itemNamespace: "cal-heatmap",
+
+    tooltip: false,
+
+    // ================================================
+    // EVENTS CALLBACK
+    // ================================================
+
+    // Callback when clicking on a time block
+    onClick: null,
+
+    // Callback after painting the empty calendar
+    // Can be used to trigger an API call, once the calendar is ready to be filled
+    afterLoad: null,
+
+    // Callback after loading the next domain in the calendar
+    afterLoadNextDomain: null,
+
+    // Callback after loading the previous domain in the calendar
+    afterLoadPreviousDomain: null,
+
+    // Callback after finishing all actions on the calendar
+    onComplete: null,
+
+    // Callback after fetching the datas, but before applying them to the calendar
+    // Used mainly to convert the datas if they're not formatted like expected
+    // Takes the fetched "data" object as argument, must return a json object
+    // formatted like {timestamp:count, timestamp2:count2},
+    afterLoadData: function(data) { return data; },
+
+    // Callback triggered after calling and completing update().
+    afterUpdate: null,
+
+    // Callback triggered after calling next().
+    // The `status` argument is equal to true if there is no
+    // more next domain to load
+    //
+    // This callback is also executed once, after calling previous(),
+    // only when the max domain is reached
+    onMaxDomainReached: null,
+
+    // Callback triggered after calling previous().
+    // The `status` argument is equal to true if there is no
+    // more previous domain to load
+    //
+    // This callback is also executed once, after calling next(),
+    // only when the min domain is reached
+    onMinDomainReached: null
+  };
+
+  this._domainType = {
+    "min": {
+      name: "minute",
+      level: 10,
+      maxItemNumber: 60,
+      defaultRowNumber: 10,
+      defaultColumnNumber: 6,
+      row: function(d) { return self.getSubDomainRowNumber(d); },
+      column: function(d) { return self.getSubDomainColumnNumber(d); },
+      position: {
+        x: function(d) { return Math.floor(d.getMinutes() / self._domainType.min.row(d)); },
+        y: function(d) { return d.getMinutes() % self._domainType.min.row(d); }
+      },
+      format: {
+        date: "%H:%M, %A %B %-e, %Y",
+        legend: "",
+        connector: "at"
+      },
+      extractUnit: function(d) {
+        return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes()).getTime();
+      }
+    },
+    "hour": {
+      name: "hour",
+      level: 20,
+      maxItemNumber: function(d) {
+        switch(self.options.domain) {
+        case "day":
+          return 24;
+        case "week":
+          return 24 * 7;
+        case "month":
+          return 24 * (self.options.domainDynamicDimension ? self.getDayCountInMonth(d): 31);
+        }
+      },
+      defaultRowNumber: 6,
+      defaultColumnNumber: function(d) {
+        switch(self.options.domain) {
+        case "day":
+          return 4;
+        case "week":
+          return 28;
+        case "month":
+          return self.options.domainDynamicDimension ? self.getDayCountInMonth(d): 31;
+        }
+      },
+      row: function(d) { return self.getSubDomainRowNumber(d); },
+      column: function(d) { return self.getSubDomainColumnNumber(d); },
+      position: {
+        x: function(d) {
+          if (self.options.domain === "month") {
+            if (self.options.colLimit > 0 || self.options.rowLimit > 0) {
+              return Math.floor((d.getHours() + (d.getDate()-1)*24) / self._domainType.hour.row(d));
+            }
+            return Math.floor(d.getHours() / self._domainType.hour.row(d)) + (d.getDate()-1)*4;
+          } else if (self.options.domain === "week") {
+            if (self.options.colLimit > 0 || self.options.rowLimit > 0) {
+              return Math.floor((d.getHours() + self.getWeekDay(d)*24) / self._domainType.hour.row(d));
+            }
+            return Math.floor(d.getHours() / self._domainType.hour.row(d)) + self.getWeekDay(d)*4;
+          }
+          return Math.floor(d.getHours() / self._domainType.hour.row(d));
+        },
+        y: function(d) {
+          var p = d.getHours();
+          if (self.options.colLimit > 0 || self.options.rowLimit > 0) {
+            switch(self.options.domain) {
+            case "month":
+              p += (d.getDate()-1) * 24;
+              break;
+            case "week":
+              p += self.getWeekDay(d) * 24;
+              break;
+            }
+          }
+          return Math.floor(p % self._domainType.hour.row(d));
+        }
+      },
+      format: {
+        date: "%Hh, %A %B %-e, %Y",
+        legend: "%H:00",
+        connector: "at"
+      },
+      extractUnit: function(d) {
+        return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime();
+      }
+    },
+    "day": {
+      name: "day",
+      level: 30,
+      maxItemNumber: function(d) {
+        switch(self.options.domain) {
+        case "week":
+          return 7;
+        case "month":
+          return self.options.domainDynamicDimension ? self.getDayCountInMonth(d) : 31;
+        case "year":
+          return self.options.domainDynamicDimension ? self.getDayCountInYear(d) : 366;
+        }
+      },
+      defaultColumnNumber: function(d) {
+        d = new Date(d);
+        switch(self.options.domain) {
+        case "week":
+          return 1;
+        case "month":
+          return (self.options.domainDynamicDimension && !self.options.verticalOrientation) ? (self.getWeekNumber(new Date(d.getFullYear(), d.getMonth()+1, 0)) - self.getWeekNumber(d) + 1): 6;
+        case "year":
+          return (self.options.domainDynamicDimension ? (self.getWeekNumber(new Date(d.getFullYear(), 11, 31)) - self.getWeekNumber(new Date(d.getFullYear(), 0)) + 1): 54);
+        }
+      },
+      defaultRowNumber: 7,
+      row: function(d) { return self.getSubDomainRowNumber(d); },
+      column: function(d) { return self.getSubDomainColumnNumber(d); },
+      position: {
+        x: function(d) {
+          switch(self.options.domain) {
+          case "week":
+            return Math.floor(self.getWeekDay(d) / self._domainType.day.row(d));
+          case "month":
+            if (self.options.colLimit > 0 || self.options.rowLimit > 0) {
+              return Math.floor((d.getDate() - 1)/ self._domainType.day.row(d));
+            }
+            return self.getWeekNumber(d) - self.getWeekNumber(new Date(d.getFullYear(), d.getMonth()));
+          case "year":
+            if (self.options.colLimit > 0 || self.options.rowLimit > 0) {
+              return Math.floor((self.getDayOfYear(d) - 1) / self._domainType.day.row(d));
+            }
+            return self.getWeekNumber(d);
+          }
+        },
+        y: function(d) {
+          var p = self.getWeekDay(d);
+          if (self.options.colLimit > 0 || self.options.rowLimit > 0) {
+            switch(self.options.domain) {
+            case "year":
+              p = self.getDayOfYear(d) - 1;
+              break;
+            case "week":
+              p = self.getWeekDay(d);
+              break;
+            case "month":
+              p = d.getDate() - 1;
+              break;
+            }
+          }
+          return Math.floor(p % self._domainType.day.row(d));
+        }
+      },
+      format: {
+        date: "%A %B %-e, %Y",
+        legend: "%e %b",
+        connector: "on"
+      },
+      extractUnit: function(d) {
+        return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
+      }
+    },
+    "week": {
+      name: "week",
+      level: 40,
+      maxItemNumber: 54,
+      defaultColumnNumber: function(d) {
+        d = new Date(d);
+        switch(self.options.domain) {
+        case "year":
+          return self._domainType.week.maxItemNumber;
+        case "month":
+          return self.options.domainDynamicDimension ? self.getWeekNumber(new Date(d.getFullYear(), d.getMonth()+1, 0)) - self.getWeekNumber(d) : 5;
+        }
+      },
+      defaultRowNumber: 1,
+      row: function(d) { return self.getSubDomainRowNumber(d); },
+      column: function(d) { return self.getSubDomainColumnNumber(d); },
+      position: {
+        x: function(d) {
+          switch(self.options.domain) {
+          case "year":
+            return Math.floor(self.getWeekNumber(d) / self._domainType.week.row(d));
+          case "month":
+            return Math.floor(self.getMonthWeekNumber(d) / self._domainType.week.row(d));
+          }
+        },
+        y: function(d) {
+          return self.getWeekNumber(d) % self._domainType.week.row(d);
+        }
+      },
+      format: {
+        date: "%B Week #%W",
+        legend: "%B Week #%W",
+        connector: "in"
+      },
+      extractUnit: function(d) {
+        var dt = new Date(d.getFullYear(), d.getMonth(), d.getDate());
+        // According to ISO-8601, week number computation are based on week starting on Monday
+        var weekDay = dt.getDay() - (self.options.weekStartOnMonday ? 1 : 0);
+        if (weekDay < 0) {
+          weekDay = 6;
+        }
+        dt.setDate(dt.getDate() - weekDay);
+        return dt.getTime();
+      }
+    },
+    "month": {
+      name: "month",
+      level: 50,
+      maxItemNumber: 12,
+      defaultColumnNumber: 12,
+      defaultRowNumber: 1,
+      row: function() { return self.getSubDomainRowNumber(); },
+      column: function() { return self.getSubDomainColumnNumber(); },
+      position: {
+        x: function(d) { return Math.floor(d.getMonth() / self._domainType.month.row(d)); },
+        y: function(d) { return d.getMonth() % self._domainType.month.row(d); }
+      },
+      format: {
+        date: "%B %Y",
+        legend: "%B",
+        connector: "in"
+      },
+      extractUnit: function(d) {
+        return new Date(d.getFullYear(), d.getMonth()).getTime();
+      }
+    },
+    "year": {
+      name: "year",
+      level: 60,
+      row: function() { return self.options.rowLimit || 1; },
+      column: function() { return self.options.colLimit || 1; },
+      position: {
+        x: function() { return 1; },
+        y: function() { return 1; }
+      },
+      format: {
+        date: "%Y",
+        legend: "%Y",
+        connector: "in"
+      },
+      extractUnit: function(d) {
+        return new Date(d.getFullYear()).getTime();
+      }
+    }
+  };
+
+  for (var type in this._domainType) {
+    if (this._domainType.hasOwnProperty(type)) {
+      var d = this._domainType[type];
+      this._domainType["x_" + type] = {
+        name: "x_" + type,
+        level: d.type,
+        maxItemNumber: d.maxItemNumber,
+        defaultRowNumber: d.defaultRowNumber,
+        defaultColumnNumber: d.defaultColumnNumber,
+        row: d.column,
+        column: d.row,
+        position: {
+          x: d.position.y,
+          y: d.position.x
+        },
+        format: d.format,
+        extractUnit: d.extractUnit
+      };
+    }
+  }
+
+  // Record the address of the last inserted domain when browsing
+  this.lastInsertedSvg = null;
+
+  this._completed = false;
+
+  // Record all the valid domains
+  // Each domain value is a timestamp in milliseconds
+  this._domains = d3.map();
+
+  this.graphDim = {
+    width: 0,
+    height: 0
+  };
+
+  this.legendDim = {
+    width: 0,
+    height: 0
+  };
+
+  this.NAVIGATE_LEFT = 1;
+  this.NAVIGATE_RIGHT = 2;
+
+  // Various update mode when using the update() API
+  this.RESET_ALL_ON_UPDATE = 0;
+  this.RESET_SINGLE_ON_UPDATE = 1;
+  this.APPEND_ON_UPDATE = 2;
+
+  this.DEFAULT_LEGEND_MARGIN = 10;
+
+  this.root = null;
+  this.tooltip = null;
+
+  this._maxDomainReached = false;
+  this._minDomainReached = false;
+
+  this.domainPosition = new DomainPosition();
+  this.Legend = null;
+  this.legendScale = null;
+
+  // List of domains that are skipped because of DST
+  // All times belonging to these domains should be re-assigned to the previous domain
+  this.DSTDomain = [];
+
+  /**
+   * Display the graph for the first time
+   * @return bool True if the calendar is created
+   */
+  this._init = function() {
+
+    self.getDomain(self.options.start).map(function(d) { return d.getTime(); }).map(function(d) {
+      self._domains.set(d, self.getSubDomain(d).map(function(d) { return {t: self._domainType[self.options.subDomain].extractUnit(d), v: null}; }));
+    });
+
+    self.root = d3.select(self.options.itemSelector).append("svg").attr("class", "cal-heatmap-container");
+
+    self.root.attr("x", 0).attr("y", 0).append("svg").attr("class", "graph");
+
+    self.Legend = new Legend(self);
+
+    if (self.options.paintOnLoad) {
+      _initCalendar();
+    }
+    self.root.call(self.tip);
+    self.root.call(self.legendTip);
+
+    return true;
+  };
+
+  function _initCalendar() {
+    self.verticalDomainLabel = (self.options.label.position === "top" || self.options.label.position === "bottom");
+
+    self.domainVerticalLabelHeight = self.options.label.height === null ? Math.max(25, self.options.cellSize*2): self.options.label.height;
+    self.domainHorizontalLabelWidth = 0;
+
+    if (self.options.domainLabelFormat === "" && self.options.label.height === null) {
+      self.domainVerticalLabelHeight = 0;
+    }
+
+    if (!self.verticalDomainLabel) {
+      self.domainVerticalLabelHeight = 0;
+      self.domainHorizontalLabelWidth = self.options.label.width;
+    }
+
+    self.paint();
+
+    // =========================================================================//
+    // ATTACHING DOMAIN NAVIGATION EVENT                    //
+    // =========================================================================//
+    if (self.options.nextSelector !== false) {
+      d3.select(self.options.nextSelector).on("click." + self.options.itemNamespace, function() {
+        d3.event.preventDefault();
+        return self.loadNextDomain(1);
+      });
+    }
+
+    if (self.options.previousSelector !== false) {
+      d3.select(self.options.previousSelector).on("click." + self.options.itemNamespace, function() {
+        d3.event.preventDefault();
+        return self.loadPreviousDomain(1);
+      });
+    }
+
+    self.Legend.redraw(self.graphDim.width - self.options.domainGutter - self.options.cellPadding);
+    self.afterLoad();
+
+    var domains = self.getDomainKeys();
+
+    // Fill the graph with some datas
+    if (self.options.loadOnInit) {
+      self.getDatas(
+        self.options.data,
+        new Date(domains[0]),
+        self.getSubDomain(domains[domains.length-1]).pop(),
+        function() {
+          self.fill();
+          self.onComplete();
+        }
+      );
+    } else {
+      self.onComplete();
+    }
+
+    self.checkIfMinDomainIsReached(domains[0]);
+    self.checkIfMaxDomainIsReached(self.getNextDomain().getTime());
+  }
+
+  // Return the width of the domain block, without the domain gutter
+  // @param int d Domain start timestamp
+  function w(d, outer) {
+    var width = self.options.cellSize*self._domainType[self.options.subDomain].column(d) + self.options.cellPadding*self._domainType[self.options.subDomain].column(d);
+    if (arguments.length === 2 && outer === true) {
+      return width += self.domainHorizontalLabelWidth + self.options.domainGutter + self.options.domainMargin[1] + self.options.domainMargin[3];
+    }
+    return width;
+  }
+
+  // Return the height of the domain block, without the domain gutter
+  function h(d, outer) {
+    var height = self.options.cellSize*self._domainType[self.options.subDomain].row(d) + self.options.cellPadding*self._domainType[self.options.subDomain].row(d);
+    if (arguments.length === 2 && outer === true) {
+      height += self.options.domainGutter + self.domainVerticalLabelHeight + self.options.domainMargin[0] + self.options.domainMargin[2];
+    }
+    return height;
+  }
+
+  /**
+   *
+   *
+   * @param int navigationDir
+   */
+  this.paint = function(navigationDir) {
+
+    var options = self.options;
+
+    if (arguments.length === 0) {
+      navigationDir = false;
+    }
+
+    // Painting all the domains
+    var domainSvg = self.root.select(".graph")
+      .selectAll(".graph-domain")
+      .data(
+        function() {
+          var data = self.getDomainKeys();
+          return navigationDir === self.NAVIGATE_LEFT ? data.reverse(): data;
+        },
+        function(d) { return d; }
+      )
+    ;
+
+    var enteringDomainDim = 0;
+    var exitingDomainDim = 0;
+
+    // =========================================================================//
+    // PAINTING DOMAIN                              //
+    // =========================================================================//
+
+    var svg = domainSvg
+      .enter()
+      .append("svg")
+      .attr("width", function(d) {
+        return w(d, true);
+      })
+      .attr("height", function(d) {
+        return h(d, true);
+      })
+      .attr("x", function(d) {
+        if (options.verticalOrientation) {
+          self.graphDim.width = Math.max(self.graphDim.width, w(d, true));
+          return 0;
+        } else {
+          return getDomainPosition(d, self.graphDim, "width", w(d, true));
+        }
+      })
+      .attr("y", function(d) {
+        if (options.verticalOrientation) {
+          return getDomainPosition(d, self.graphDim, "height", h(d, true));
+        } else {
+          self.graphDim.height = Math.max(self.graphDim.height, h(d, true));
+          return 0;
+        }
+      })
+      .attr("class", function(d) {
+        var classname = "graph-domain";
+        var date = new Date(d);
+        switch(options.domain) {
+        case "hour":
+          classname += " h_" + date.getHours();
+          /* falls through */
+        case "day":
+          classname += " d_" + date.getDate() + " dy_" + date.getDay();
+          /* falls through */
+        case "week":
+          classname += " w_" + self.getWeekNumber(date);
+          /* falls through */
+        case "month":
+          classname += " m_" + (date.getMonth() + 1);
+          /* falls through */
+        case "year":
+          classname += " y_" + date.getFullYear();
+        }
+        return classname;
+      })
+    ;
+
+    self.lastInsertedSvg = svg;
+
+    function getDomainPosition(domainIndex, graphDim, axis, domainDim) {
+      var tmp = 0;
+      switch(navigationDir) {
+      case false:
+        tmp = graphDim[axis];
+
+        graphDim[axis] += domainDim;
+        self.domainPosition.setPosition(domainIndex, tmp);
+        return tmp;
+
+      case self.NAVIGATE_RIGHT:
+        self.domainPosition.setPosition(domainIndex, graphDim[axis]);
+
+        enteringDomainDim = domainDim;
+        exitingDomainDim = self.domainPosition.getPositionFromIndex(1);
+
+        self.domainPosition.shiftRightBy(exitingDomainDim);
+        return graphDim[axis];
+
+      case self.NAVIGATE_LEFT:
+        tmp = -domainDim;
+
+        enteringDomainDim = -tmp;
+        exitingDomainDim = graphDim[axis] - self.domainPosition.getLast();
+
+        self.domainPosition.setPosition(domainIndex, tmp);
+        self.domainPosition.shiftLeftBy(enteringDomainDim);
+        return tmp;
+      }
+    }
+
+    svg.append("rect")
+      .attr("width", function(d) { return w(d, true) - options.domainGutter - options.cellPadding; })
+      .attr("height", function(d) { return h(d, true) - options.domainGutter - options.cellPadding; })
+      .attr("class", "domain-background")
+    ;
+
+    // =========================================================================//
+    // PAINTING SUBDOMAINS                            //
+    // =========================================================================//
+    var subDomainSvgGroup = svg.append("svg")
+      .attr("x", function() {
+        if (options.label.position === "left") {
+          return self.domainHorizontalLabelWidth + options.domainMargin[3];
+        } else {
+          return options.domainMargin[3];
+        }
+      })
+      .attr("y", function() {
+        if (options.label.position === "top") {
+          return self.domainVerticalLabelHeight + options.domainMargin[0];
+        } else {
+          return options.domainMargin[0];
+        }
+      })
+      .attr("class", "graph-subdomain-group")
+    ;
+
+    var rect = subDomainSvgGroup
+      .selectAll("g")
+      .data(function(d) { return self._domains.get(d); })
+      .enter()
+      .append("g")
+    ;
+
+    rect
+      .append("rect")
+      .attr("class", function(d) {
+        return "graph-rect" + self.getHighlightClassName(d.t) + (options.onClick !== null ? " hover_cursor": "");
+      })
+      .attr("width", options.cellSize)
+      .attr("height", options.cellSize)
+      .attr("x", function(d) { return self.positionSubDomainX(d.t); })
+      .attr("y", function(d) { return self.positionSubDomainY(d.t); })
+      .on("click", function(d) {
+        if (options.onClick !== null) {
+          return self.onClick(new Date(d.t), d.v);
+        }
+      })
+      .call(function(selection) {
+        if (options.cellRadius > 0) {
+          selection
+            .attr("rx", options.cellRadius)
+            .attr("ry", options.cellRadius)
+          ;
+        }
+
+        if (self.legendScale !== null && options.legendColors !== null && options.legendColors.hasOwnProperty("base")) {
+          selection.attr("fill", options.legendColors.base);
+        }
+
+        if (options.tooltip) {
+          selection
+          .on("mouseover", function(d) {
+            self.tip.show(d);
+          })
+          .on("mouseout", function() {
+            self.tip.hide(d);
+          });
+        }
+      })
+    ;
+
+    // Appending a title to each subdomain
+    if (!options.tooltip) {
+      rect.append("title").text(function(d){ return self.formatDate(new Date(d.t), options.subDomainDateFormat); });
+    }
+
+    // =========================================================================//
+    // PAINTING LABEL                              //
+    // =========================================================================//
+    if (options.domainLabelFormat !== "") {
+      svg.append("text")
+        .attr("class", "graph-label")
+        .attr("y", function(d) {
+          var y = options.domainMargin[0];
+          switch(options.label.position) {
+          case "top":
+            y += self.domainVerticalLabelHeight/2;
+            break;
+          case "bottom":
+            y += h(d) + self.domainVerticalLabelHeight/2;
+          }
+
+          return y + options.label.offset.y *
+          (
+            ((options.label.rotate === "right" && options.label.position === "right") ||
+            (options.label.rotate === "left" && options.label.position === "left")) ?
+            -1: 1
+          );
+        })
+        .attr("x", function(d){
+          var x = options.domainMargin[3];
+          switch(options.label.position) {
+          case "right":
+            x += w(d);
+            break;
+          case "bottom":
+          case "top":
+            x += w(d)/2;
+          }
+
+          if (options.label.align === "right") {
+            return x + self.domainHorizontalLabelWidth - options.label.offset.x *
+            (options.label.rotate === "right" ? -1: 1);
+          }
+          return x + options.label.offset.x;
+
+        })
+        .attr("text-anchor", function() {
+          switch(options.label.align) {
+          case "start":
+          case "left":
+            return "start";
+          case "end":
+          case "right":
+            return "end";
+          default:
+            return "middle";
+          }
+        })
+        .attr("dominant-baseline", function() { return self.verticalDomainLabel ? "middle": "top"; })
+        .text(function(d) { return self.formatDate(new Date(d), options.domainLabelFormat); })
+        .call(domainRotate)
+      ;
+    }
+
+    function domainRotate(selection) {
+      switch (options.label.rotate) {
+      case "right":
+        selection
+        .attr("transform", function(d) {
+          var s = "rotate(90), ";
+          switch(options.label.position) {
+          case "right":
+            s += "translate(-" + w(d) + " , -" + w(d) + ")";
+            break;
+          case "left":
+            s += "translate(0, -" + self.domainHorizontalLabelWidth + ")";
+            break;
+          }
+
+          return s;
+        });
+        break;
+      case "left":
+        selection
+        .attr("transform", function(d) {
+          var s = "rotate(270), ";
+          switch(options.label.position) {
+          case "right":
+            s += "translate(-" + (w(d) + self.domainHorizontalLabelWidth) + " , " + w(d) + ")";
+            break;
+          case "left":
+            s += "translate(-" + (self.domainHorizontalLabelWidth) + " , " + self.domainHorizontalLabelWidth + ")";
+            break;
+          }
+
+          return s;
+        });
+        break;
+      }
+    }
+
+    // =========================================================================//
+    // PAINTING DOMAIN SUBDOMAIN CONTENT                    //
+    // =========================================================================//
+    if (options.subDomainTextFormat !== null) {
+      rect
+        .append("text")
+        .attr("class", function(d) { return "subdomain-text" + self.getHighlightClassName(d.t); })
+        .attr("x", function(d) { return self.positionSubDomainX(d.t) + options.cellSize/2; })
+        .attr("y", function(d) { return self.positionSubDomainY(d.t) + options.cellSize/2; })
+        .attr("text-anchor", "middle")
+        .attr("dominant-baseline", "central")
+        .text(function(d){
+          return self.formatDate(new Date(d.t), options.subDomainTextFormat);
+        })
+      ;
+    }
+
+    // =========================================================================//
+    // ANIMATION                                //
+    // =========================================================================//
+
+    if (navigationDir !== false) {
+      domainSvg.transition().duration(options.animationDuration)
+        .attr("x", function(d){
+          return options.verticalOrientation ? 0: self.domainPosition.getPosition(d);
+        })
+        .attr("y", function(d){
+          return options.verticalOrientation? self.domainPosition.getPosition(d): 0;
+        })
+      ;
+    }
+
+    var tempWidth = self.graphDim.width;
+    var tempHeight = self.graphDim.height;
+
+    if (options.verticalOrientation) {
+      self.graphDim.height += enteringDomainDim - exitingDomainDim;
+    } else {
+      self.graphDim.width += enteringDomainDim - exitingDomainDim;
+    }
+
+    // At the time of exit, domainsWidth and domainsHeight already automatically shifted
+    domainSvg.exit().transition().duration(options.animationDuration)
+      .attr("x", function(d){
+        if (options.verticalOrientation) {
+          return 0;
+        } else {
+          switch(navigationDir) {
+          case self.NAVIGATE_LEFT:
+            return Math.min(self.graphDim.width, tempWidth);
+          case self.NAVIGATE_RIGHT:
+            return -w(d, true);
+          }
+        }
+      })
+      .attr("y", function(d){
+        if (options.verticalOrientation) {
+          switch(navigationDir) {
+          case self.NAVIGATE_LEFT:
+            return Math.min(self.graphDim.height, tempHeight);
+          case self.NAVIGATE_RIGHT:
+            return -h(d, true);
+          }
+        } else {
+          return 0;
+        }
+      })
+      .remove()
+    ;
+
+    // Resize the root container
+    self.resize();
+  };
+};
+
+CalHeatMap.prototype = {
+
+  /**
+   * Validate and merge user settings with default settings
+   *
+   * @param  {object} settings User settings
+   * @return {bool} False if settings contains error
+   */
+  /* jshint maxstatements:false */
+  init: function(settings) {
+    "use strict";
+
+    var parent = this;
+
+    var options = parent.options = mergeRecursive(parent.options, settings);
+
+    // Fatal errors
+    // Stop script execution on error
+    validateDomainType();
+    validateSelector(options.itemSelector, false, "itemSelector");
+
+    if (parent.allowedDataType.indexOf(options.dataType) === -1) {
+      throw new Error("The data type '" + options.dataType + "' is not valid data type");
+    }
+
+    if (d3.select(options.itemSelector)[0][0] === null) {
+      throw new Error("The node '" + options.itemSelector + "' specified in itemSelector does not exists");
+    }
+
+    try {
+      validateSelector(options.nextSelector, true, "nextSelector");
+      validateSelector(options.previousSelector, true, "previousSelector");
+    } catch(error) {
+      console.log(error.message);
+      return false;
+    }
+
+    // If other settings contains error, will fallback to default
+
+    if (!settings.hasOwnProperty("subDomain")) {
+      this.options.subDomain = getOptimalSubDomain(settings.domain);
+    }
+
+    if (typeof options.itemNamespace !== "string" || options.itemNamespace === "") {
+      console.log("itemNamespace can not be empty, falling back to cal-heatmap");
+      options.itemNamespace = "cal-heatmap";
+    }
+
+    // Don't touch these settings
+    var s = ["data", "onComplete", "onClick", "afterLoad", "afterLoadData", "afterLoadPreviousDomain", "afterLoadNextDomain", "afterUpdate"];
+
+    for (var k in s) {
+      if (settings.hasOwnProperty(s[k])) {
+        options[s[k]] = settings[s[k]];
+      }
+    }
+
+    options.subDomainDateFormat = (typeof options.subDomainDateFormat === "string" || typeof options.subDomainDateFormat === "function" ? options.subDomainDateFormat : this._domainType[options.subDomain].format.date);
+    options.domainLabelFormat = (typeof options.domainLabelFormat === "string" || typeof options.domainLabelFormat === "function" ? options.domainLabelFormat : this._domainType[options.domain].format.legend);
+    options.subDomainTextFormat = ((typeof options.subDomainTextFormat === "string" && options.subDomainTextFormat !== "") || typeof options.subDomainTextFormat === "function" ? options.subDomainTextFormat : null);
+    options.domainMargin = expandMarginSetting(options.domainMargin);
+    options.legendMargin = expandMarginSetting(options.legendMargin);
+    options.highlight = parent.expandDateSetting(options.highlight);
+    options.itemName = expandItemName(options.itemName);
+    options.colLimit = parseColLimit(options.colLimit);
+    options.rowLimit = parseRowLimit(options.rowLimit);
+    if (!settings.hasOwnProperty("legendMargin")) {
+      autoAddLegendMargin();
+    }
+    autoAlignLabel();
+
+    /**
+     * Validate that a queryString is valid
+     *
+     * @param  {Element|string|bool} selector   The queryString to test
+     * @param  {bool}  canBeFalse  Whether false is an accepted and valid value
+     * @param  {string} name    Name of the tested selector
+     * @throws {Error}        If the selector is not valid
+     * @return {bool}        True if the selector is a valid queryString
+     */
+    function validateSelector(selector, canBeFalse, name) {
+      if (((canBeFalse && selector === false) || selector instanceof Element || typeof selector === "string") && selector !== "") {
+        return true;
+      }
+      throw new Error("The " + name + " is not valid");
+    }
+
+    /**
+     * Return the optimal subDomain for the specified domain
+     *
+     * @param  {string} domain a domain name
+     * @return {string}        the subDomain name
+     */
+    function getOptimalSubDomain(domain) {
+      switch(domain) {
+      case "year":
+        return "month";
+      case "month":
+        return "day";
+      case "week":
+        return "day";
+      case "day":
+        return "hour";
+      default:
+        return "min";
+      }
+    }
+
+    /**
+     * Ensure that the domain and subdomain are valid
+     *
+     * @throw {Error} when domain or subdomain are not valid
+     * @return {bool} True if domain and subdomain are valid and compatible
+     */
+    function validateDomainType() {
+      if (!parent._domainType.hasOwnProperty(options.domain) || options.domain === "min" || options.domain.substring(0, 2) === "x_") {
+        throw new Error("The domain '" + options.domain + "' is not valid");
+      }
+
+      if (!parent._domainType.hasOwnProperty(options.subDomain) || options.subDomain === "year") {
+        throw new Error("The subDomain '" + options.subDomain + "' is not valid");
+      }
+
+      if (parent._domainType[options.domain].level <= parent._domainType[options.subDomain].level) {
+        throw new Error("'" + options.subDomain + "' is not a valid subDomain to '" + options.domain +  "'");
+      }
+
+      return true;
+    }
+
+    /**
+     * Fine-tune the label alignement depending on its position
+     *
+     * @return void
+     */
+    function autoAlignLabel() {
+      // Auto-align label, depending on it's position
+      if (!settings.hasOwnProperty("label") || (settings.hasOwnProperty("label") && !settings.label.hasOwnProperty("align"))) {
+        switch(options.label.position) {
+        case "left":
+          options.label.align = "right";
+          break;
+        case "right":
+          options.label.align = "left";
+          break;
+        default:
+          options.label.align = "center";
+        }
+
+        if (options.label.rotate === "left") {
+          options.label.align = "right";
+        } else if (options.label.rotate === "right") {
+          options.label.align = "left";
+        }
+      }
+
+      if (!settings.hasOwnProperty("label") || (settings.hasOwnProperty("label") && !settings.label.hasOwnProperty("offset"))) {
+        if (options.label.position === "left" || options.label.position === "right") {
+          options.label.offset = {
+            x: 10,
+            y: 15
+          };
+        }
+      }
+    }
+
+    /**
+     * If not specified, add some margin around the legend depending on its position
+     *
+     * @return void
+     */
+    function autoAddLegendMargin() {
+      switch(options.legendVerticalPosition) {
+      case "top":
+        options.legendMargin[2] = parent.DEFAULT_LEGEND_MARGIN;
+        break;
+      case "bottom":
+        options.legendMargin[0] = parent.DEFAULT_LEGEND_MARGIN;
+        break;
+      case "middle":
+      case "center":
+        options.legendMargin[options.legendHorizontalPosition === "right" ? 3 : 1] = parent.DEFAULT_LEGEND_MARGIN;
+      }
+    }
+
+    /**
+     * Expand a number of an array of numbers to an usable 4 values array
+     *
+     * @param  {integer|array} value
+     * @return {array}        array
+     */
+    function expandMarginSetting(value) {
+      if (typeof value === "number") {
+        value = [value];
+      }
+
+      if (!Array.isArray(value)) {
+        console.log("Margin only takes an integer or an array of integers");
+        value = [0];
+      }
+
+      switch(value.length) {
+      case 1:
+        return [value[0], value[0], value[0], value[0]];
+      case 2:
+        return [value[0], value[1], value[0], value[1]];
+      case 3:
+        return [value[0], value[1], value[2], value[1]];
+      case 4:
+        return value;
+      default:
+        return value.slice(0, 4);
+      }
+    }
+
+    /**
+     * Convert a string to an array like [singular-form, plural-form]
+     *
+     * @param  {string|array} value Date to convert
+     * @return {array}       An array like [singular-form, plural-form]
+     */
+    function expandItemName(value) {
+      if (typeof value === "string") {
+        return [value, value + (value !== "" ? "s" : "")];
+      }
+
+      if (Array.isArray(value)) {
+        if (value.length === 1) {
+          return [value[0], value[0] + "s"];
+        } else if (value.length > 2) {
+          return value.slice(0, 2);
+        }
+
+        return value;
+      }
+
+      return ["item", "items"];
+    }
+
+    function parseColLimit(value) {
+      return value > 0 ? value : null;
+    }
+
+    function parseRowLimit(value) {
+      if (value > 0 && options.colLimit > 0) {
+        console.log("colLimit and rowLimit are mutually exclusive, rowLimit will be ignored");
+        return null;
+      }
+      return value > 0 ? value : null;
+    }
+
+    return this._init();
+
+  },
+
+  /**
+   * Convert a keyword or an array of keyword/date to an array of date objects
+   *
+   * @param  {string|array|Date} value Data to convert
+   * @return {array}       An array of Dates
+   */
+  expandDateSetting: function(value) {
+    "use strict";
+
+    if (!Array.isArray(value)) {
+      value = [value];
+    }
+
+    return value.map(function(data) {
+      if (data === "now") {
+        return new Date();
+      }
+      if (data instanceof Date) {
+        return data;
+      }
+      return false;
+    }).filter(function(d) { return d !== false; });
+  },
+
+  /**
+   * Fill the calendar by coloring the cells
+   *
+   * @param array svg An array of html node to apply the transformation to (optional)
+   *                  It's used to limit the painting to only a subset of the calendar
+   * @return void
+   */
+  fill: function(svg) {
+    "use strict";
+
+    var parent = this;
+    var options = parent.options;
+
+    if (arguments.length === 0) {
+      svg = parent.root.selectAll(".graph-domain");
+    }
+
+    var rect = svg
+      .selectAll("svg").selectAll("g")
+      .data(function(d) { return parent._domains.get(d); })
+    ;
+
+    /**
+     * Colorize the cell via a style attribute if enabled
+     */
+    function addStyle(element) {
+      if (parent.legendScale === null) {
+        return false;
+      }
+
+      element.attr("fill", function(d) {
+        if (d.v === null && (options.hasOwnProperty("considerMissingDataAsZero") && !options.considerMissingDataAsZero)) {
+          if (options.legendColors.hasOwnProperty("base")) {
+            return options.legendColors.base;
+          }
+        }
+
+        if (options.legendColors !== null && options.legendColors.hasOwnProperty("empty") &&
+          (d.v === 0 || (d.v === null && options.hasOwnProperty("considerMissingDataAsZero") && options.considerMissingDataAsZero))
+        ) {
+          return options.legendColors.empty;
+        }
+
+        if (d.v < 0 && options.legend[0] > 0 && options.legendColors !== null && options.legendColors.hasOwnProperty("overflow")) {
+          return options.legendColors.overflow;
+        }
+
+        return parent.legendScale(Math.min(d.v, options.legend[options.legend.length-1]));
+      });
+    }
+
+    rect.transition().duration(options.animationDuration).select("rect")
+      .attr("class", function(d) {
+
+        var htmlClass = parent.getHighlightClassName(d.t).trim().split(" ");
+        var pastDate = parent.dateIsLessThan(d.t, new Date());
+        var sameDate = parent.dateIsEqual(d.t, new Date());
+
+        if (parent.legendScale === null ||
+          (d.v === null && (options.hasOwnProperty("considerMissingDataAsZero") && !options.considerMissingDataAsZero) &&!options.legendColors.hasOwnProperty("base"))
+        ) {
+          htmlClass.push("graph-rect");
+        }
+
+        if (sameDate) {
+          htmlClass.push("now");
+        } else if (!pastDate) {
+          htmlClass.push("future");
+        }
+
+        if (d.v !== null) {
+          htmlClass.push(parent.Legend.getClass(d.v, (parent.legendScale === null)));
+        } else if (options.considerMissingDataAsZero && pastDate) {
+          htmlClass.push(parent.Legend.getClass(0, (parent.legendScale === null)));
+        }
+
+        if (options.onClick !== null) {
+          htmlClass.push("hover_cursor");
+        }
+
+        return htmlClass.join(" ");
+      })
+      .call(addStyle)
+    ;
+
+    rect.transition().duration(options.animationDuration).select("title")
+      .text(function(d) { return parent.getSubDomainTitle(d); })
+    ;
+
+    function formatSubDomainText(element) {
+      if (typeof options.subDomainTextFormat === "function") {
+        element.text(function(d) { return options.subDomainTextFormat(d.t, d.v); });
+      }
+    }
+
+    /**
+     * Change the subDomainText class if necessary
+     * Also change the text, e.g when text is representing the value
+     * instead of the date
+     */
+    rect.transition().duration(options.animationDuration).select("text")
+      .attr("class", function(d) { return "subdomain-text" + parent.getHighlightClassName(d.t); })
+      .call(formatSubDomainText)
+    ;
+  },
+
+  /**
+   * Sprintf like function.
+   * Replaces placeholders {0} in string with values from provided object.
+   *
+   * @param string formatted String containing placeholders.
+   * @param object args Object with properties to replace placeholders in string.
+   *
+   * @return String
+   */
+  formatStringWithObject: function (formatted, args) {
+    "use strict";
+    for (var prop in args) {
+      if (args.hasOwnProperty(prop)) {
+        var regexp = new RegExp("\\{" + prop + "\\}", "gi");
+        formatted = formatted.replace(regexp, args[prop]);
+      }
+    }
+    return formatted;
+  },
+
+  // =========================================================================//
+  // EVENTS CALLBACK                              //
+  // =========================================================================//
+
+  /**
+   * Helper method for triggering event callback
+   *
+   * @param  string  eventName       Name of the event to trigger
+   * @param  array  successArgs     List of argument to pass to the callback
+   * @param  boolean  skip      Whether to skip the event triggering
+   * @return mixed  True when the triggering was skipped, false on error, else the callback function
+   */
+  triggerEvent: function(eventName, successArgs, skip) {
+    "use strict";
+
+    if ((arguments.length === 3 && skip) || this.options[eventName] === null) {
+      return true;
+    }
+
+    if (typeof this.options[eventName] === "function") {
+      if (typeof successArgs === "function") {
+        successArgs = successArgs();
+      }
+      return this.options[eventName].apply(this, successArgs);
+    } else {
+      console.log("Provided callback for " + eventName + " is not a function.");
+      return false;
+    }
+  },
+
+  /**
+   * Event triggered on a mouse click on a subDomain cell
+   *
+   * @param  Date    d    Date of the subdomain block
+   * @param  int    itemNb  Number of items in that date
+   */
+  onClick: function(d, itemNb) {
+    "use strict";
+
+    return this.triggerEvent("onClick", [d, itemNb]);
+  },
+
+  /**
+   * Event triggered after drawing the calendar, byt before filling it with data
+   */
+  afterLoad: function() {
+    "use strict";
+
+    return this.triggerEvent("afterLoad");
+  },
+
+  /**
+   * Event triggered after completing drawing and filling the calendar
+   */
+  onComplete: function() {
+    "use strict";
+
+    var response = this.triggerEvent("onComplete", [], this._completed);
+    this._completed = true;
+    return response;
+  },
+
+  /**
+   * Event triggered after shifting the calendar one domain back
+   *
+   * @param  Date    start  Domain start date
+   * @param  Date    end    Domain end date
+   */
+  afterLoadPreviousDomain: function(start) {
+    "use strict";
+
+    var parent = this;
+    return this.triggerEvent("afterLoadPreviousDomain", function() {
+      var subDomain = parent.getSubDomain(start);
+      return [subDomain.shift(), subDomain.pop()];
+    });
+  },
+
+  /**
+   * Event triggered after shifting the calendar one domain above
+   *
+   * @param  Date    start  Domain start date
+   * @param  Date    end    Domain end date
+   */
+  afterLoadNextDomain: function(start) {
+    "use strict";
+
+    var parent = this;
+    return this.triggerEvent("afterLoadNextDomain", function() {
+      var subDomain = parent.getSubDomain(start);
+      return [subDomain.shift(), subDomain.pop()];
+    });
+  },
+
+  /**
+   * Event triggered after loading the leftmost domain allowed by minDate
+   *
+   * @param  boolean  reached True if the leftmost domain was reached
+   */
+  onMinDomainReached: function(reached) {
+    "use strict";
+
+    this._minDomainReached = reached;
+    return this.triggerEvent("onMinDomainReached", [reached]);
+  },
+
+  /**
+   * Event triggered after loading the rightmost domain allowed by maxDate
+   *
+   * @param  boolean  reached True if the rightmost domain was reached
+   */
+  onMaxDomainReached: function(reached) {
+    "use strict";
+
+    this._maxDomainReached = reached;
+    return this.triggerEvent("onMaxDomainReached", [reached]);
+  },
+
+  checkIfMinDomainIsReached: function(date, upperBound) {
+    "use strict";
+
+    if (this.minDomainIsReached(date)) {
+      this.onMinDomainReached(true);
+    }
+
+    if (arguments.length === 2) {
+      if (this._maxDomainReached && !this.maxDomainIsReached(upperBound)) {
+        this.onMaxDomainReached(false);
+      }
+    }
+  },
+
+  checkIfMaxDomainIsReached: function(date, lowerBound) {
+    "use strict";
+
+    if (this.maxDomainIsReached(date)) {
+      this.onMaxDomainReached(true);
+    }
+
+    if (arguments.length === 2) {
+      if (this._minDomainReached && !this.minDomainIsReached(lowerBound)) {
+        this.onMinDomainReached(false);
+      }
+    }
+  },
+
+  afterUpdate: function() {
+    "use strict";
+
+    return this.triggerEvent("afterUpdate");
+  },
+
+  // =========================================================================//
+  // FORMATTER                                //
+  // =========================================================================//
+
+  formatNumber: d3.format(",g"),
+
+  formatDate: function(d, format) {
+    "use strict";
+
+    if (arguments.length < 2) {
+      format = "title";
+    }
+
+    if (typeof format === "function") {
+      return format(d);
+    } else {
+      var f = d3.time.format(format);
+      return f(d);
+    }
+  },
+
+  getSubDomainTitle: function(d) {
+    "use strict";
+
+    if (d.v === null && !this.options.considerMissingDataAsZero) {
+      return this.formatStringWithObject(this.options.subDomainTitleFormat.empty , {
+        date: this.formatDate(new Date(d.t), this.options.subDomainDateFormat)
+      });
+    } else {
+      var value = d.v;
+      // Consider null as 0
+      if (value === null && this.options.considerMissingDataAsZero) {
+        value = 0;
+      }
+
+      return this.formatStringWithObject(this.options.subDomainTitleFormat.filled, {
+        count: this.formatNumber(value),
+        name: this.options.itemName[(value !== 1 ? 1: 0)],
+        connector: this._domainType[this.options.subDomain].format.connector,
+        date: this.formatDate(new Date(d.t), this.options.subDomainDateFormat)
+      });
+    }
+  },
+
+  // =========================================================================//
+  // DOMAIN NAVIGATION                            //
+  // =========================================================================//
+
+  /**
+   * Shift the calendar one domain forward
+   *
+   * The new domain is loaded only if it's not beyond maxDate
+   *
+   * @param int n Number of domains to load
+   * @return bool True if the next domain was loaded, else false
+   */
+  loadNextDomain: function(n) {
+    "use strict";
+
+    if (this._maxDomainReached || n === 0) {
+      return false;
+    }
+
+    var bound = this.loadNewDomains(this.NAVIGATE_RIGHT, this.getDomain(this.getNextDomain(), n));
+
+    this.afterLoadNextDomain(bound.end);
+    this.checkIfMaxDomainIsReached(this.getNextDomain().getTime(), bound.start);
+
+    return true;
+  },
+
+  /**
+   * Shift the calendar one domain backward
+   *
+   * The previous domain is loaded only if it's not beyond the minDate
+   *
+   * @param int n Number of domains to load
+   * @return bool True if the previous domain was loaded, else false
+   */
+  loadPreviousDomain: function(n) {
+    "use strict";
+
+    if (this._minDomainReached || n === 0) {
+      return false;
+    }
+
+    var bound = this.loadNewDomains(this.NAVIGATE_LEFT, this.getDomain(this.getDomainKeys()[0], -n).reverse());
+
+    this.afterLoadPreviousDomain(bound.start);
+    this.checkIfMinDomainIsReached(bound.start, bound.end);
+
+    return true;
+  },
+
+  loadNewDomains: function(direction, newDomains) {
+    "use strict";
+
+    var parent = this;
+    var backward = direction === this.NAVIGATE_LEFT;
+    var i = -1;
+    var total = newDomains.length;
+    var domains = this.getDomainKeys();
+
+    function buildSubDomain(d) {
+      return {t: parent._domainType[parent.options.subDomain].extractUnit(d), v: null};
+    }
+
+    // Remove out of bound domains from list of new domains to prepend
+    while (++i < total) {
+      if (backward && this.minDomainIsReached(newDomains[i])) {
+        newDomains = newDomains.slice(0, i+1);
+        break;
+      }
+      if (!backward && this.maxDomainIsReached(newDomains[i])) {
+        newDomains = newDomains.slice(0, i);
+        break;
+      }
+    }
+
+    newDomains = newDomains.slice(-this.options.range);
+
+    for (i = 0, total = newDomains.length; i < total; i++) {
+      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(error, data) {
+      if (afterLoad !== false) {
+        if (typeof afterLoad === "function") {
+          data = afterLoad(data);
+        } else if (typeof (self.options.afterLoadData) === "function") {
+          data = self.options.afterLoadData(data);
+        } else {
+          console.log("Provided callback for afterLoadData is not a function.");
+        }
+      } else if (self.options.dataType === "csv" || self.options.dataType === "tsv") {
+        data = this.interpretCSV(data);
+      }
+      self.parseDatas(data, updateMode, startDate, endDate);
+      if (typeof callback === "function") {
+        callback();
+      }
+    };
+
+    switch(typeof source) {
+    case "string":
+      if (source === "") {
+        _callback(null, {});
+        return true;
+      } else {
+        var url = this.parseURI(source, startDate, endDate);
+        var requestType = "GET";
+        if (self.options.dataPostPayload !== null ) {
+          requestType = "POST";
+        }
+        var payload = null;
+        if (self.options.dataPostPayload !== null) {
+          payload = this.parseURI(self.options.dataPostPayload, startDate, endDate);
+        }
+
+        var xhr = null;
+        switch(this.options.dataType) {
+        case "json":
+          xhr = d3.json(url);
+          break;
+        case "csv":
+          xhr = d3.csv(url);
+          break;
+        case "tsv":
+          xhr = d3.tsv(url);
+          break;
+        case "txt":
+          xhr = d3.text(url, "text/plain");
+          break;
+        }
+
+        // jshint maxdepth:5
+        if (self.options.dataRequestHeaders !== null) {
+          for (var header in self.options.dataRequestHeaders) {
+            if (self.options.dataRequestHeaders.hasOwnProperty(header)) {
+              xhr.header(header, self.options.dataRequestHeaders[header]);
+            }
+          }
+        }
+
+        xhr.send(requestType, payload, _callback);
+      }
+      return false;
+    case "object":
+      if (source === Object(source)) {
+        _callback(null, source);
+        return false;
+      }
+      /* falls through */
+    default:
+      _callback(null, {});
+      return true;
+    }
+  },
+
+  /**
+   * Populate the calendar internal data
+   *
+   * @param object data
+   * @param constant updateMode
+   * @param Date startDate
+   * @param Date endDate
+   *
+   * @return void
+   */
+  parseDatas: function(data, updateMode, startDate, endDate) {
+    "use strict";
+
+    if (updateMode === this.RESET_ALL_ON_UPDATE) {
+      this._domains.forEach(function(key, value) {
+        value.forEach(function(element, index, array) {
+          array[index].v = null;
+        });
+      });
+    }
+
+    var temp = {};
+
+    var extractTime = function(d) { return d.t; };
+
+    /*jshint forin:false */
+    for (var d in data) {
+
+      var date = new Date(d*1000);
+      var domainUnit = this.getDomain(date)[0].getTime();
+      // The current data belongs to a domain that was compressed
+      // Compress the data for the two duplicate hours into the same hour
+      if (this.DSTDomain.indexOf(domainUnit) >= 0) {
+
+        // Re-assign all data to the first or the second duplicate hours
+        // depending on which is visible
+        if (this._domains.has(domainUnit - 3600 * 1000)) {
+          domainUnit -= 3600 * 1000;
+        }
+      }
+
+      // Skip if data is not relevant to current domain
+      if (isNaN(d) || !data.hasOwnProperty(d) || !this._domains.has(domainUnit) || !(domainUnit >= +startDate && domainUnit < +endDate)) {
+        continue;
+      }
+
+      var subDomainsData = this._domains.get(domainUnit);
+
+      if (!temp.hasOwnProperty(domainUnit)) {
+        temp[domainUnit] = subDomainsData.map(extractTime);
+      }
+
+      var index = temp[domainUnit].indexOf(this._domainType[this.options.subDomain].extractUnit(date));
+
+      if (updateMode === this.RESET_SINGLE_ON_UPDATE) {
+        subDomainsData[index].v = data[d];
+      } else {
+        if (!isNaN(subDomainsData[index].v)) {
+          subDomainsData[index].v += data[d];
+        } else {
+          subDomainsData[index].v = data[d];
+        }
+      }
+    }
+  },
+
+  parseURI: function(str, startDate, endDate) {
+    "use strict";
+
+    // Use a timestamp in seconds
+    str = str.replace(/\{\{t:start\}\}/g, startDate.getTime()/1000);
+    str = str.replace(/\{\{t:end\}\}/g, endDate.getTime()/1000);
+
+    // Use a string date, following the ISO-8601
+    str = str.replace(/\{\{d:start\}\}/g, startDate.toISOString());
+    str = str.replace(/\{\{d:end\}\}/g, endDate.toISOString());
+
+    return str;
+  },
+
+  interpretCSV: function(data) {
+    "use strict";
+
+    var d = {};
+    var keys = Object.keys(data[0]);
+    var i, total;
+    for (i = 0, total = data.length; i < total; i++) {
+      d[data[i][keys[0]]] = +data[i][keys[1]];
+    }
+    return d;
+  },
+
+  /**
+   * Handle the calendar layout and dimension
+   *
+   * Expand and shrink the container depending on its children dimension
+   * Also rearrange the children position depending on their dimension,
+   * and the legend position
+   *
+   * @return void
+   */
+  resize: function() {
+    "use strict";
+
+    var parent = this;
+    var options = parent.options;
+    var legendWidth = options.displayLegend ? (parent.Legend.getDim("width") + options.legendMargin[1] + options.legendMargin[3]) : 0;
+    var legendHeight = options.displayLegend ? (parent.Legend.getDim("height") + options.legendMargin[0] + options.legendMargin[2]) : 0;
+
+    var graphWidth = parent.graphDim.width - options.domainGutter - options.cellPadding;
+    var graphHeight = parent.graphDim.height - options.domainGutter - options.cellPadding;
+
+    this.root.transition().duration(options.animationDuration)
+      .attr("width", function() {
+        if (options.legendVerticalPosition === "middle" || options.legendVerticalPosition === "center") {
+          return graphWidth + legendWidth;
+        }
+        return Math.max(graphWidth, legendWidth);
+      })
+      .attr("height", function() {
+        if (options.legendVerticalPosition === "middle" || options.legendVerticalPosition === "center") {
+          return Math.max(graphHeight, legendHeight);
+        }
+        return graphHeight + legendHeight;
+      })
+    ;
+
+    this.root.select(".graph").transition().duration(options.animationDuration)
+      .attr("y", function() {
+        if (options.legendVerticalPosition === "top") {
+          return legendHeight;
+        }
+        return 0;
+      })
+      .attr("x", function() {
+        if (
+          (options.legendVerticalPosition === "middle" || options.legendVerticalPosition === "center") &&
+          options.legendHorizontalPosition === "left") {
+          return legendWidth;
+        }
+        return 0;
+
+      })
+    ;
+  },
+
+  // =========================================================================//
+  // PUBLIC API                                //
+  // =========================================================================//
+
+  /**
+   * Shift the calendar forward
+   */
+  next: function(n) {
+    "use strict";
+
+    if (arguments.length === 0) {
+      n = 1;
+    }
+    return this.loadNextDomain(n);
+  },
+
+  /**
+   * Shift the calendar backward
+   */
+  previous: function(n) {
+    "use strict";
+
+    if (arguments.length === 0) {
+      n = 1;
+    }
+    return this.loadPreviousDomain(n);
+  },
+
+  /**
+   * Jump directly to a specific date
+   *
+   * JumpTo will scroll the calendar until the wanted domain with the specified
+   * date is visible. Unless you set reset to true, the wanted domain
+   * will not necessarily be the first (leftmost) domain of the calendar.
+   *
+   * @param Date date Jump to the domain containing that date
+   * @param bool reset Whether the wanted domain should be the first domain of the calendar
+   * @param bool True of the calendar was scrolled
+   */
+  jumpTo: function(date, reset) {
+    "use strict";
+
+    if (arguments.length < 2) {
+      reset = false;
+    }
+    var domains = this.getDomainKeys();
+    var firstDomain = domains[0];
+    var lastDomain = domains[domains.length-1];
+
+    if (date < firstDomain) {
+      return this.loadPreviousDomain(this.getDomain(firstDomain, date).length);
+    } else {
+      if (reset) {
+        return this.loadNextDomain(this.getDomain(firstDomain, date).length);
+      }
+
+      if (date > lastDomain) {
+        return this.loadNextDomain(this.getDomain(lastDomain, date).length);
+      }
+    }
+
+    return false;
+  },
+
+  /**
+   * Navigate back to the start date
+   *
+   * @since  3.3.8
+   * @return void
+   */
+  rewind: function() {
+    "use strict";
+
+    this.jumpTo(this.options.start, true);
+  },
+
+  /**
+   * Update the calendar with new data
+   *
+   * @param  object|string    dataSource    The calendar's datasource, same type as this.options.data
+   * @param  boolean|function    afterLoad    Whether to execute afterLoad() on the data. Pass directly a function
+   * if you don't want to use the afterLoad() callback
+   */
+  update: function(dataSource, afterLoad, updateMode) {
+    "use strict";
+
+    if (arguments.length === 0) {
+      dataSource = this.options.data;
+    }
+    if (arguments.length < 2) {
+      afterLoad = true;
+    }
+    if (arguments.length < 3) {
+      updateMode = this.RESET_ALL_ON_UPDATE;
+    }
+
+    var domains = this.getDomainKeys();
+    var self = this;
+    this.getDatas(
+      dataSource,
+      new Date(domains[0]),
+      this.getSubDomain(domains[domains.length-1]).pop(),
+      function() {
+        self.fill();
+        self.afterUpdate();
+      },
+      afterLoad,
+      updateMode
+    );
+  },
+
+  /**
+   * Set the legend
+   *
+   * @param array legend an array of integer, representing the different threshold value
+   * @param array colorRange an array of 2 hex colors, for the minimum and maximum colors
+   */
+  setLegend: function() {
+    "use strict";
+
+    var oldLegend = this.options.legend.slice(0);
+    if (arguments.length >= 1 && Array.isArray(arguments[0])) {
+      this.options.legend = arguments[0];
+    }
+    if (arguments.length >= 2) {
+      if (Array.isArray(arguments[1]) && arguments[1].length >= 2) {
+        this.options.legendColors = [arguments[1][0], arguments[1][1]];
+      } else {
+        this.options.legendColors = arguments[1];
+      }
+    }
+
+    if ((arguments.length > 0 && !arrayEquals(oldLegend, this.options.legend)) || arguments.length >= 2) {
+      this.Legend.buildColors();
+      this.fill();
+    }
+
+    this.Legend.redraw(this.graphDim.width - this.options.domainGutter - this.options.cellPadding);
+  },
+
+  /**
+   * Remove the legend
+   *
+   * @return bool False if there is no legend to remove
+   */
+  removeLegend: function() {
+    "use strict";
+
+    if (!this.options.displayLegend) {
+      return false;
+    }
+    this.options.displayLegend = false;
+    this.Legend.remove();
+    return true;
+  },
+
+  /**
+   * Display the legend
+   *
+   * @return bool False if the legend was already displayed
+   */
+  showLegend: function() {
+    "use strict";
+
+    if (this.options.displayLegend) {
+      return false;
+    }
+    this.options.displayLegend = true;
+    this.Legend.redraw(this.graphDim.width - this.options.domainGutter - this.options.cellPadding);
+    return true;
+  },
+
+  /**
+   * Highlight dates
+   *
+   * Add a highlight class to a set of dates
+   *
+   * @since  3.3.5
+   * @param  array Array of dates to highlight
+   * @return bool True if dates were highlighted
+   */
+  highlight: function(args) {
+    "use strict";
+
+    if ((this.options.highlight = this.expandDateSetting(args)).length > 0) {
+      this.fill();
+      return true;
+    }
+    return false;
+  },
+
+  /**
+   * Destroy the calendar
+   *
+   * Usage: cal = cal.destroy();
+   *
+   * @since  3.3.6
+   * @param function A callback function to trigger after destroying the calendar
+   * @return null
+   */
+  destroy: function(callback) {
+    "use strict";
+
+    this.root.transition().duration(this.options.animationDuration)
+      .attr("width", 0)
+      .attr("height", 0)
+      .remove()
+      .each("end", function() {
+        if (typeof callback === "function") {
+          callback();
+        } else if (typeof callback !== "undefined") {
+          console.log("Provided callback for destroy() is not a function.");
+        }
+      })
+    ;
+
+    return null;
+  },
+
+  getSVG: function() {
+    "use strict";
+
+    var styles = {
+      ".cal-heatmap-container": {},
+      ".graph": {},
+      ".graph-rect": {},
+      "rect.highlight": {},
+      "rect.now": {},
+      "rect.highlight-now": {},
+      "text.highlight": {},
+      "text.now": {},
+      "text.highlight-now": {},
+      ".domain-background": {},
+      ".graph-label": {},
+      ".subdomain-text": {},
+      ".q0": {},
+      ".qi": {}
+    };
+
+    for (var j = 1, total = this.options.legend.length+1; j <= total; j++) {
+      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("rx", options.legendCellRadius)
+      .attr("ry", options.legendCellRadius)
+      .attr("x", function(d, i) {
+        return i * (options.legendCellSize + options.legendCellPadding);
+      })
+    ;
+  }
+
+  legendItem.select("title").text(function(d, i) {
+    if (i === 0) {
+      return calendar.formatStringWithObject(options.legendTitleFormat.lower, {
+        min: options.legend[i],
+        name: options.itemName[1]
+      });
+    } else if (i === _legend.length-1) {
+      return calendar.formatStringWithObject(options.legendTitleFormat.upper, {
+        max: options.legend[i-1],
+        name: options.itemName[1]
+      });
+    } else {
+      return calendar.formatStringWithObject(options.legendTitleFormat.inner, {
+        down: options.legend[i-1],
+        up: options.legend[i],
+        name: options.itemName[1]
+      });
+    }
+  });
+  legendItem
+  .on("mouseover", function(d) {
+    calendar.legendTip.show(d);
+  })
+  .on("mouseout", function() {
+    calendar.legendTip.hide();
+  });
+
+  legend.transition().duration(options.animationDuration)
+    .attr("x", getLegendXPosition())
+    .attr("y", getLegendYPosition())
+    .attr("width", parent.getDim("width"))
+    .attr("height", parent.getDim("height"))
+  ;
+
+  legend.select("g").transition().duration(options.animationDuration)
+    .attr("transform", function() {
+      if (options.legendOrientation === "vertical")  {
+        return "rotate(90 " + options.legendCellSize/2 + " " + options.legendCellSize/2 + ")";
+      }
+      return "";
+    })
+  ;
+
+  function getLegendXPosition() {
+    switch(options.legendHorizontalPosition) {
+    case "right":
+      if (options.legendVerticalPosition === "center" || options.legendVerticalPosition === "middle") {
+        return width + options.legendMargin[3];
+      }
+      return width - parent.getDim("width") - options.legendMargin[1];
+    case "middle":
+    case "center":
+      return Math.round(width/2 - parent.getDim("width")/2);
+    default:
+      return options.legendMargin[3];
+    }
+  }
+
+  function getLegendYPosition() {
+    if (options.legendVerticalPosition === "bottom") {
+      return calendar.graphDim.height + options.legendMargin[0] - options.domainGutter - options.cellPadding;
+    }
+    return options.legendMargin[0];
+  }
+
+  calendar.resize();
+};
+
+/**
+ * Return the dimension of the legend
+ *
+ * Takes into account rotation
+ *
+ * @param  string axis Width or height
+ * @return int height or width in pixels
+ */
+Legend.prototype.getDim = function(axis) {
+  "use strict";
+
+  var isHorizontal = (this.calendar.options.legendOrientation === "horizontal");
+
+  switch(axis) {
+  case "width":
+    return this.dim[isHorizontal ? "width": "height"];
+  case "height":
+    return this.dim[isHorizontal ? "height": "width"];
+  }
+};
+
+Legend.prototype.buildColors = function() {
+  "use strict";
+
+  var options = this.calendar.options; // Shorter accessor for variable name mangling when minifying
+
+  if (options.legendColors === null) {
+    this.calendar.legendScale = null;
+    return false;
+  }
+
+  var _colorRange = [];
+
+  if (Array.isArray(options.legendColors)) {
+    _colorRange = options.legendColors;
+  } else if (options.legendColors.hasOwnProperty("min") && options.legendColors.hasOwnProperty("max")) {
+    _colorRange = [options.legendColors.min, options.legendColors.max];
+  } else {
+    options.legendColors = null;
+    return false;
+  }
+
+  var _legend = options.legend.slice(0);
+
+  if (_legend[0] > 0) {
+    _legend.unshift(0);
+  } else if (_legend[0] <= 0) {
+    // Let's guess the leftmost value, it we have to add one
+    _legend.unshift(_legend[0] - (_legend[_legend.length-1] - _legend[0])/_legend.length);
+  }
+  var colorScale;
+  if (options.legendColors.hasOwnProperty("colorScale")) {
+    colorScale = options.legendColors.colorScale;
+  } else {
+    colorScale = d3.scale.linear()
+      .range(_colorRange)
+      .interpolate(d3.interpolateHcl)
+      .domain([d3.min(_legend), d3.max(_legend)])
+    ;
+  }
+
+  var legendColors = _legend.map(function(element) { return colorScale(element); });
+  this.calendar.legendScale = d3.scale.threshold().domain(options.legend).range(legendColors);
+
+  return true;
+};
+
+/**
+ * Return the classname on the legend for the specified value
+ *
+ * @param integer n Value associated to a date
+ * @param bool withCssClass Whether to display the css class used to style the cell.
+ *                          Disabling will allow styling directly via html fill attribute
+ *
+ * @return string Classname according to the legend
+ */
+Legend.prototype.getClass = function(n, withCssClass) {
+  "use strict";
+
+  if (n === null || isNaN(n)) {
+    return "";
+  }
+
+  var index = [this.calendar.options.legend.length + 1];
+
+  for (var i = 0, total = this.calendar.options.legend.length-1; i <= total; i++) {
+
+    if (this.calendar.options.legend[0] > 0 && n < 0) {
+      index = ["1", "i"];
+      break;
+    }
+
+    if (n <= this.calendar.options.legend[i]) {
+      index = [i+1];
+      break;
+    }
+  }
+
+  if (n === 0) {
+    index.push(0);
+  }
+
+  index.unshift("");
+  return (index.join(" r") + (withCssClass ? index.join(" q"): "")).trim();
+};
+
+/**
+ * #source http://stackoverflow.com/a/383245/805649
+ */
+function mergeRecursive(obj1, obj2) {
+  "use strict";
+
+  /*jshint forin:false */
+  for (var p in obj2) {
+    try {
+      // Property in destination object set; update its value.
+      if (obj2[p].constructor === Object) {
+        obj1[p] = mergeRecursive(obj1[p], obj2[p]);
+      } else {
+        obj1[p] = obj2[p];
+      }
+    } catch(e) {
+      // Property in destination object not set; create it and set its value.
+      obj1[p] = obj2[p];
+    }
+  }
+
+  return obj1;
+}
+
+/**
+ * Check if 2 arrays are equals
+ *
+ * @link http://stackoverflow.com/a/14853974/805649
+ * @param  array array the array to compare to
+ * @return bool true of the 2 arrays are equals
+ */
+function arrayEquals(arrayA, arrayB) {
+  "use strict";
+
+  // if the other array is a falsy value, return
+  if (!arrayB || !arrayA) {
+    return false;
+  }
+
+  // compare lengths - can save a lot of time
+  if (arrayA.length !== arrayB.length) {
+    return false;
+  }
+
+  for (var i = 0; i < arrayA.length; i++) {
+    // 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;
+}
diff --git a/superset/assets/visualizations/cal_heatmap.css b/superset/assets/visualizations/cal_heatmap.css
index e5e46f6..b6be2d0 100644
--- a/superset/assets/visualizations/cal_heatmap.css
+++ b/superset/assets/visualizations/cal_heatmap.css
@@ -1,9 +1,14 @@
-.cal_heatmap .slice_container {
+.slice_container.cal_heatmap {
   padding: 10px;
   position: static !important;
+  overflow: auto !important;
 }
 
 .cal_heatmap .slice_container .ch-tooltip {
   margin-left: 20px;
   margin-top: 5px;
 }
+.graph-legend rect {
+  stroke: #aaa;
+  stroke-location: inside;
+}
diff --git a/superset/assets/visualizations/cal_heatmap.js b/superset/assets/visualizations/cal_heatmap.js
index 6b34acb..e7c396c 100644
--- a/superset/assets/visualizations/cal_heatmap.js
+++ b/superset/assets/visualizations/cal_heatmap.js
@@ -1,38 +1,83 @@
-// JS
 import d3 from 'd3';
 
-// CSS
-require('./cal_heatmap.css');
-require('../node_modules/cal-heatmap/cal-heatmap.css');
+import { colorScalerFactory } from '../javascripts/modules/colors';
+import CalHeatMap from '../vendor/cal-heatmap/cal-heatmap';
+import '../vendor/cal-heatmap/cal-heatmap.css';
+import { d3TimeFormatPreset, d3FormatPreset } from '../javascripts/modules/utils';
+import './cal_heatmap.css';
+import { UTC } from '../javascripts/modules/dates';
 
-const CalHeatMap = require('cal-heatmap');
+const UTCTS = uts => UTC(new Date(uts)).getTime();
 
 function calHeatmap(slice, payload) {
-  const div = d3.select(slice.selector);
+  const fd = slice.formData;
+  const steps = fd.steps;
+  const valueFormatter = d3FormatPreset(fd.y_axis_format);
+  const timeFormatter = d3TimeFormatPreset(fd.x_axis_time_format);
+
+  const container = d3.select(slice.selector).style('height', slice.height());
+  container.selectAll('*').remove();
+  const div = container.append('div');
   const data = payload.data;
 
-  div.selectAll('*').remove();
-  const cal = new CalHeatMap();
+  const subDomainTextFormat = fd.show_values ? (date, value) => valueFormatter(value) : null;
+  const cellPadding = fd.cell_padding !== '' ? fd.cell_padding : 2;
+  const cellRadius = fd.cell_radius || 0;
+  const cellSize = fd.cell_size || 10;
+
+  // Trick to convert all timestamps to UTC
+  const metricsData = {};
+  Object.keys(data.data).forEach((metric) => {
+    metricsData[metric] = {};
+    Object.keys(data.data[metric]).forEach((ts) => {
+      metricsData[metric][UTCTS(ts * 1000) / 1000] = data.data[metric][ts];
+    });
+  });
+
+  Object.keys(metricsData).forEach((metric) => {
+    const calContainer = div.append('div');
+    if (fd.show_metric_name) {
+      calContainer.append('h4').text(slice.verboseMetricName(metric));
+    }
+    const timestamps = metricsData[metric];
+    const extents = d3.extent(Object.keys(timestamps), key => timestamps[key]);
+    const step = (extents[1] - extents[0]) / (steps - 1);
+    const colorScale = colorScalerFactory(fd.linear_color_scheme, null, null, extents);
+
+    const legend = d3.range(steps).map(i => extents[0] + (step * i));
+    const legendColors = legend.map(colorScale);
 
-  const timestamps = data.timestamps;
-  const extents = d3.extent(Object.keys(timestamps), key => timestamps[key]);
-  const step = (extents[1] - extents[0]) / 5;
+    const cal = new CalHeatMap();
 
-  try {
     cal.init({
-      start: data.start,
+      start: UTCTS(data.start),
       data: timestamps,
-      itemSelector: slice.selector,
+      itemSelector: calContainer[0][0],
+      legendVerticalPosition: 'top',
+      cellSize,
+      cellPadding,
+      cellRadius,
+      legendCellSize: cellSize,
+      legendCellPadding: 2,
+      legendCellRadius: cellRadius,
       tooltip: true,
       domain: data.domain,
       subDomain: data.subdomain,
       range: data.range,
       browsing: true,
-      legend: [extents[0], extents[0] + step, extents[0] + (step * 2), extents[0] + (step * 3)],
+      legend,
+      legendColors: {
+        colorScale,
+        min: legendColors[0],
+        max: legendColors[legendColors.length - 1],
+        empty: 'white',
+      },
+      displayLegend: fd.show_legend,
+      itemName: '',
+      valueFormatter,
+      timeFormatter,
+      subDomainTextFormat,
     });
-  } catch (e) {
-    slice.error(e);
-  }
+  });
 }
-
 module.exports = calHeatmap;
diff --git a/superset/assets/yarn.lock b/superset/assets/yarn.lock
index f77c1c6..5ebc447 100644
--- a/superset/assets/yarn.lock
+++ b/superset/assets/yarn.lock
@@ -1761,12 +1761,6 @@ cacache@^10.0.1:
     unique-filename "^1.1.0"
     y18n "^3.2.1"
 
-cal-heatmap@3.6.2:
-  version "3.6.2"
-  resolved "https://registry.yarnpkg.com/cal-heatmap/-/cal-heatmap-3.6.2.tgz#961a7f4686b3bdcf7104d951b6ff1dd58c0c62d1"
-  dependencies:
-    d3 "^3.0.6"
-
 call-matcher@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/call-matcher/-/call-matcher-1.0.1.tgz#5134d077984f712a54dad3cbf62de28dce416ca8"
@@ -2652,7 +2646,7 @@ d3-zoom@^1.3.0:
     d3-selection "1"
     d3-transition "1"
 
-d3@3, d3@^3.0.6, d3@^3.5.17, d3@^3.5.5, d3@^3.5.6:
+d3@3, d3@^3.5.17, d3@^3.5.5, d3@^3.5.6:
   version "3.5.17"
   resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.17.tgz#bc46748004378b21a360c9fc7cf5231790762fb8"
 
diff --git a/superset/migrations/versions/5ccf602336a0_.py b/superset/migrations/versions/5ccf602336a0_.py
new file mode 100644
index 0000000..540c5fc
--- /dev/null
+++ b/superset/migrations/versions/5ccf602336a0_.py
@@ -0,0 +1,22 @@
+"""empty message
+
+Revision ID: 5ccf602336a0
+Revises: ('130915240929', 'c9495751e314')
+Create Date: 2018-04-12 16:00:47.639218
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '5ccf602336a0'
+down_revision = ('130915240929', 'c9495751e314')
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+    pass
+
+
+def downgrade():
+    pass
diff --git a/superset/migrations/versions/bf706ae5eb46_cal_heatmap_metric_to_metrics.py b/superset/migrations/versions/bf706ae5eb46_cal_heatmap_metric_to_metrics.py
new file mode 100644
index 0000000..3504280
--- /dev/null
+++ b/superset/migrations/versions/bf706ae5eb46_cal_heatmap_metric_to_metrics.py
@@ -0,0 +1,56 @@
+"""cal_heatmap_metric_to_metrics
+
+Revision ID: bf706ae5eb46
+Revises: f231d82b9b26
+Create Date: 2018-04-10 11:19:47.621878
+
+"""
+from alembic import op
+
+import json
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy import Column, Integer, String, Text
+
+from superset import db
+from superset.legacy import cast_form_data
+
+Base = declarative_base()
+
+# revision identifiers, used by Alembic.
+revision = 'bf706ae5eb46'
+down_revision = 'f231d82b9b26'
+
+
+class Slice(Base):
+    """Declarative class to do query in upgrade"""
+    __tablename__ = 'slices'
+    id = Column(Integer, primary_key=True)
+    datasource_type = Column(String(200))
+    viz_type = Column(String(200))
+    slice_name = Column(String(200))
+    params = Column(Text)
+
+
+def upgrade():
+    bind = op.get_bind()
+    session = db.Session(bind=bind)
+
+    slices = session.query(Slice).filter_by(viz_type='cal_heatmap').all()
+    slice_len = len(slices)
+    for i, slc in enumerate(slices):
+        try:
+            params = json.loads(slc.params or '{}')
+            params['metrics'] = [params.get('metric')]
+            del params['metric']
+            slc.params = json.dumps(params, indent=2, sort_keys=True)
+            session.merge(slc)
+            session.commit()
+            print('Upgraded ({}/{}): {}'.format(i, slice_len, slc.slice_name))
+        except Exception as e:
+            print(slc.slice_name + ' error: ' + str(e))
+
+    session.close()
+
+
+def downgrade():
+    pass
diff --git a/superset/migrations/versions/c9495751e314_.py b/superset/migrations/versions/c9495751e314_.py
new file mode 100644
index 0000000..53381e7
--- /dev/null
+++ b/superset/migrations/versions/c9495751e314_.py
@@ -0,0 +1,22 @@
+"""empty message
+
+Revision ID: c9495751e314
+Revises: ('30bb17c0dc76', 'bf706ae5eb46')
+Create Date: 2018-04-10 20:46:57.890773
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = 'c9495751e314'
+down_revision = ('30bb17c0dc76', 'bf706ae5eb46')
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+    pass
+
+
+def downgrade():
+    pass
diff --git a/superset/migrations/versions/f231d82b9b26_.py b/superset/migrations/versions/f231d82b9b26_.py
index 8c64f5d..98379ba 100644
--- a/superset/migrations/versions/f231d82b9b26_.py
+++ b/superset/migrations/versions/f231d82b9b26_.py
@@ -5,16 +5,15 @@ Revises: e68c4473c581
 Create Date: 2018-03-20 19:47:54.991259
 
 """
-
-# revision identifiers, used by Alembic.
-revision = 'f231d82b9b26'
-down_revision = 'e68c4473c581'
-
 from alembic import op
 import sqlalchemy as sa
 
 from superset.utils import generic_find_uq_constraint_name
 
+# revision identifiers, used by Alembic.
+revision = 'f231d82b9b26'
+down_revision = 'e68c4473c581'
+
 conv = {
     'uq': 'uq_%(table_name)s_%(column_0_name)s',
 }
@@ -44,8 +43,12 @@ def upgrade():
                 [column, 'datasource_id'],
             )
 
+
 def downgrade():
 
+    bind = op.get_bind()
+    insp = sa.engine.reflection.Inspector.from_engine(bind)
+
     # Restore the size of the metric_name column.
     with op.batch_alter_table('metrics', naming_convention=conv) as batch_op:
         batch_op.alter_column(
diff --git a/superset/viz.py b/superset/viz.py
index dee7e56..5ec9a23 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -735,12 +735,18 @@ class CalHeatmapViz(BaseViz):
     def get_data(self, df):
         form_data = self.form_data
 
-        df.columns = ['timestamp', 'metric']
-        timestamps = {str(obj['timestamp'].value / 10**9):
-                      obj.get('metric') for obj in df.to_dict('records')}
+        data = {}
+        records = df.to_dict('records')
+        for metric in self.metrics:
+            data[metric] = {
+                str(obj[DTTM_ALIAS].value / 10**9): obj.get(metric)
+                for obj in records
+            }
 
         start = utils.parse_human_datetime(form_data.get('since'))
         end = utils.parse_human_datetime(form_data.get('until'))
+        if not start or not end:
+            raise Exception("Please provide both time bounds (Since and Until)")
         domain = form_data.get('domain_granularity')
         diff_delta = rdelta.relativedelta(end, start)
         diff_secs = (end - start).total_seconds()
@@ -757,7 +763,7 @@ class CalHeatmapViz(BaseViz):
             range_ = diff_secs // (60 * 60) + 1
 
         return {
-            'timestamps': timestamps,
+            'data': data,
             'start': start,
             'domain': domain,
             'subdomain': form_data.get('subdomain_granularity'),
@@ -765,9 +771,10 @@ class CalHeatmapViz(BaseViz):
         }
 
     def query_obj(self):
-        qry = super(CalHeatmapViz, self).query_obj()
-        qry['metrics'] = [self.form_data['metric']]
-        return qry
+        d = super(CalHeatmapViz, self).query_obj()
+        fd = self.form_data
+        d['metrics'] = fd.get('metrics')
+        return d
 
 
 class NVD3Viz(BaseViz):

-- 
To stop receiving notification emails like this one, please contact
maximebeauchemin@apache.org.