superset cal-heatmap 源码

  • 2022-10-20
  • 浏览 (344)

superset cal-heatmap 代码

文件路径:/superset-frontend/plugins/legacy-plugin-chart-calendar/src/vendor/cal-heatmap.js

// [LICENSE TBD]
/* Copied and altered from http://cal-heatmap.com/ , alterations around:
 * - tuning tooltips
 * - supporting multi-colors scales
 * - legend format
 * - UTC handling
 */

/* eslint-disable */

import d3tip from 'd3-tip';
import { getContrastingColor } from '@superset-ui/core';

var d3 = typeof require === 'function' ? require('d3') : window.d3;

var d3 = typeof require === 'function' ? require('d3') : window.d3;

var CalHeatMap = function () {
  'use strict';

  var self = this;
  self.tip = d3tip()
    .attr('class', 'd3-tip')
    .direction('n')
    .offset([-5, 0])
    .html(
      d => `
      ${self.options.timeFormatter(d.t)}: <strong>${self.options.valueFormatter(
        d.v,
      )}</strong>
    `,
    );
  self.legendTip = d3tip()
    .attr('class', 'd3-tip')
    .direction('n')
    .offset([-5, 0])
    .html(d => self.options.valueFormatter(d));

  this.allowedDataType = ['json', 'csv', 'tsv', 'txt'];

  // Default settings
  this.options = {
    // selector string of the container to append the graph to
    // Accept any string value accepted by document.querySelector or CSS3
    // or an Element object
    itemSelector: '#cal-heatmap',

    // Whether to paint the calendar on init()
    // Used by testsuite to reduce testing time
    paintOnLoad: true,

    // ================================================
    // DOMAIN
    // ================================================

    // Number of domain to display on the graph
    range: 12,

    // Size of each cell, in pixel
    cellSize: 10,

    // Padding between each cell, in pixel
    cellPadding: 2,

    // For rounded subdomain rectangles, in pixels
    cellRadius: 0,

    domainGutter: 2,

    domainMargin: [0, 0, 0, 0],

    valueFormatter: d => d,

    timeFormatter: d => d,

    domain: 'hour',

    subDomain: 'min',

    // Number of columns to split the subDomains to
    // If not null, will takes precedence over rowLimit
    colLimit: null,

    // Number of rows to split the subDomains to
    // Will be ignored if colLimit is not null
    rowLimit: null,

    // First day of the week is Monday
    // 0 to start the week on Sunday
    weekStartOnMonday: true,

    // Start date of the graph
    // @default now
    start: new Date(),

    minDate: null,

    maxDate: null,

    // ================================================
    // DATA
    // ================================================

    // Data source
    // URL, where to fetch the original datas
    data: '',

    // Data type
    // Default: json
    dataType: this.allowedDataType[0],

    // Payload sent when using POST http method
    // Leave to null (default) for GET request
    // Expect a string, formatted like "a=b;c=d"
    dataPostPayload: null,

    // Additional headers sent when requesting data
    // Expect an object formatted like:
    // { 'X-CSRF-TOKEN': 'token' }
    dataRequestHeaders: null,

    // Whether to consider missing date:value from the datasource
    // as equal to 0, or just leave them as missing
    considerMissingDataAsZero: false,

    // Load remote data on calendar creation
    // When false, the calendar will be left empty
    loadOnInit: true,

    // Calendar orientation
    // false: display domains side by side
    // true : display domains one under the other
    verticalOrientation: false,

    // Domain dynamic width/height
    // The width on a domain depends on the number of
    domainDynamicDimension: true,

    // Domain Label properties
    label: {
      // valid: top, right, bottom, left
      position: 'bottom',

      // Valid: left, center, right
      // Also valid are the direct svg values: start, middle, end
      align: 'center',

      // By default, there is no margin/padding around the label
      offset: {
        x: 0,
        y: 0,
      },

      rotate: null,

      // Used only on vertical orientation
      width: 100,

      // Used only on horizontal orientation
      height: null,
    },

    // ================================================
    // LEGEND
    // ================================================

    // Threshold for the legend
    legend: [10, 20, 30, 40],

    // Whether to display the legend
    displayLegend: true,

    legendCellSize: 10,

    legendCellPadding: 2,

    legendMargin: [0, 0, 0, 0],

    // Legend vertical position
    // top: place legend above calendar
    // bottom: place legend below the calendar
    legendVerticalPosition: 'bottom',

    // Legend horizontal position
    // accepted values: left, center, right
    legendHorizontalPosition: 'left',

    // Legend rotation
    // accepted values: horizontal, vertical
    legendOrientation: 'horizontal',

    // Objects holding all the heatmap different colors
    // null to disable, and use the default css styles
    //
    // Examples:
    // legendColors: {
    //    min: "green",
    //    max: "red",
    //    empty: "#ffffff",
    //    base: "grey",
    //    overflow: "red",
    //    colorScaler: null,
    // }
    legendColors: null,

    // ================================================
    // HIGHLIGHT
    // ================================================

    // List of dates to highlight
    // Valid values:
    // - []: don't highlight anything
    // - "now": highlight the current date
    // - an array of Date objects: highlight the specified dates
    highlight: [],

    // ================================================
    // TEXT FORMATTING / i18n
    // ================================================

    // Name of the items to represent in the calendar
    itemName: ['item', 'items'],

    // Formatting of the domain label
    // @default: null, will use the formatting according to domain type
    // Accept a string used as specifier by d3.time.format()
    // or a function
    //
    // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting
    // for accepted date formatting used by d3.time.format()
    domainLabelFormat: null,

    // Formatting of the title displayed when hovering a subDomain cell
    subDomainTitleFormat: {
      empty: '{date}',
      filled: '{count} {name} {connector} {date}',
    },

    // Formatting of the {date} used in subDomainTitleFormat
    // @default: null, will use the formatting according to subDomain type
    // Accept a string used as specifier by d3.time.format()
    // or a function
    //
    // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting
    // for accepted date formatting used by d3.time.format()
    subDomainDateFormat: null,

    // Formatting of the text inside each subDomain cell
    // @default: null, no text
    // Accept a string used as specifier by d3.time.format()
    // or a function
    //
    // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting
    // for accepted date formatting used by d3.time.format()
    subDomainTextFormat: null,

    // Formatting of the title displayed when hovering a legend cell
    legendTitleFormat: {
      lower: 'less than {min} {name}',
      inner: 'between {down} and {up} {name}',
      upper: 'more than {max} {name}',
    },

    // Animation duration, in ms
    animationDuration: 500,

    nextSelector: false,

    previousSelector: false,

    itemNamespace: 'cal-heatmap',

    tooltip: false,

    // ================================================
    // EVENTS CALLBACK
    // ================================================

    // Callback when clicking on a time block
    onClick: null,

    // Callback after painting the empty calendar
    // Can be used to trigger an API call, once the calendar is ready to be filled
    afterLoad: null,

    // Callback after loading the next domain in the calendar
    afterLoadNextDomain: null,

    // Callback after loading the previous domain in the calendar
    afterLoadPreviousDomain: null,

    // Callback after finishing all actions on the calendar
    onComplete: null,

    // Callback after fetching the datas, but before applying them to the calendar
    // Used mainly to convert the datas if they're not formatted like expected
    // Takes the fetched "data" object as argument, must return a json object
    // formatted like {timestamp:count, timestamp2:count2},
    afterLoadData: function (data) {
      return data;
    },

    // Callback triggered after calling and completing update().
    afterUpdate: null,

    // Callback triggered after calling next().
    // The `status` argument is equal to true if there is no
    // more next domain to load
    //
    // This callback is also executed once, after calling previous(),
    // only when the max domain is reached
    onMaxDomainReached: null,

    // Callback triggered after calling previous().
    // The `status` argument is equal to true if there is no
    // more previous domain to load
    //
    // This callback is also executed once, after calling next(),
    // only when the min domain is reached
    onMinDomainReached: null,
  };

  this._domainType = {
    min: {
      name: 'minute',
      level: 10,
      maxItemNumber: 60,
      defaultRowNumber: 10,
      defaultColumnNumber: 6,
      row: function (d) {
        return self.getSubDomainRowNumber(d);
      },
      column: function (d) {
        return self.getSubDomainColumnNumber(d);
      },
      position: {
        x: function (d) {
          return Math.floor(d.getMinutes() / self._domainType.min.row(d));
        },
        y: function (d) {
          return d.getMinutes() % self._domainType.min.row(d);
        },
      },
      format: {
        date: '%H:%M, %A %B %-e, %Y',
        legend: '',
        connector: 'at',
      },
      extractUnit: function (d) {
        return new Date(
          d.getFullYear(),
          d.getMonth(),
          d.getDate(),
          d.getHours(),
          d.getMinutes(),
        ).getTime();
      },
    },
    hour: {
      name: 'hour',
      level: 20,
      maxItemNumber: function (d) {
        switch (self.options.domain) {
          case 'day':
            return 24;
          case 'week':
            return 24 * 7;
          case 'month':
            return (
              24 *
              (self.options.domainDynamicDimension
                ? self.getDayCountInMonth(d)
                : 31)
            );
        }
      },
      defaultRowNumber: 6,
      defaultColumnNumber: function (d) {
        switch (self.options.domain) {
          case 'day':
            return 4;
          case 'week':
            return 28;
          case 'month':
            return self.options.domainDynamicDimension
              ? self.getDayCountInMonth(d)
              : 31;
        }
      },
      row: function (d) {
        return self.getSubDomainRowNumber(d);
      },
      column: function (d) {
        return self.getSubDomainColumnNumber(d);
      },
      position: {
        x: function (d) {
          if (self.options.domain === 'month') {
            if (self.options.colLimit > 0 || self.options.rowLimit > 0) {
              return Math.floor(
                (d.getHours() + (d.getDate() - 1) * 24) /
                  self._domainType.hour.row(d),
              );
            }
            return (
              Math.floor(d.getHours() / self._domainType.hour.row(d)) +
              (d.getDate() - 1) * 4
            );
          } else if (self.options.domain === 'week') {
            if (self.options.colLimit > 0 || self.options.rowLimit > 0) {
              return Math.floor(
                (d.getHours() + self.getWeekDay(d) * 24) /
                  self._domainType.hour.row(d),
              );
            }
            return (
              Math.floor(d.getHours() / self._domainType.hour.row(d)) +
              self.getWeekDay(d) * 4
            );
          }
          return Math.floor(d.getHours() / self._domainType.hour.row(d));
        },
        y: function (d) {
          var p = d.getHours();
          if (self.options.colLimit > 0 || self.options.rowLimit > 0) {
            switch (self.options.domain) {
              case 'month':
                p += (d.getDate() - 1) * 24;
                break;
              case 'week':
                p += self.getWeekDay(d) * 24;
                break;
            }
          }
          return Math.floor(p % self._domainType.hour.row(d));
        },
      },
      format: {
        date: '%Hh, %A %B %-e, %Y',
        legend: '%H:00',
        connector: 'at',
      },
      extractUnit: function (d) {
        return new Date(
          d.getFullYear(),
          d.getMonth(),
          d.getDate(),
          d.getHours(),
        ).getTime();
      },
    },
    day: {
      name: 'day',
      level: 30,
      maxItemNumber: function (d) {
        switch (self.options.domain) {
          case 'week':
            return 7;
          case 'month':
            return self.options.domainDynamicDimension
              ? self.getDayCountInMonth(d)
              : 31;
          case 'year':
            return self.options.domainDynamicDimension
              ? self.getDayCountInYear(d)
              : 366;
        }
      },
      defaultColumnNumber: function (d) {
        d = new Date(d);
        switch (self.options.domain) {
          case 'week':
            return 1;
          case 'month':
            return self.options.domainDynamicDimension &&
              !self.options.verticalOrientation
              ? self.getWeekNumber(
                  new Date(d.getFullYear(), d.getMonth() + 1, 0),
                ) -
                  self.getWeekNumber(d) +
                  1
              : 6;
          case 'year':
            return self.options.domainDynamicDimension
              ? self.getWeekNumber(new Date(d.getFullYear(), 11, 31)) -
                  self.getWeekNumber(new Date(d.getFullYear(), 0)) +
                  1
              : 54;
        }
      },
      defaultRowNumber: 7,
      row: function (d) {
        return self.getSubDomainRowNumber(d);
      },
      column: function (d) {
        return self.getSubDomainColumnNumber(d);
      },
      position: {
        x: function (d) {
          switch (self.options.domain) {
            case 'week':
              return Math.floor(
                self.getWeekDay(d) / self._domainType.day.row(d),
              );
            case 'month':
              if (self.options.colLimit > 0 || self.options.rowLimit > 0) {
                return Math.floor(
                  (d.getDate() - 1) / self._domainType.day.row(d),
                );
              }
              return (
                self.getWeekNumber(d) -
                self.getWeekNumber(new Date(d.getFullYear(), d.getMonth()))
              );
            case 'year':
              if (self.options.colLimit > 0 || self.options.rowLimit > 0) {
                return Math.floor(
                  (self.getDayOfYear(d) - 1) / self._domainType.day.row(d),
                );
              }
              return self.getWeekNumber(d);
          }
        },
        y: function (d) {
          var p = self.getWeekDay(d);
          if (self.options.colLimit > 0 || self.options.rowLimit > 0) {
            switch (self.options.domain) {
              case 'year':
                p = self.getDayOfYear(d) - 1;
                break;
              case 'week':
                p = self.getWeekDay(d);
                break;
              case 'month':
                p = d.getDate() - 1;
                break;
            }
          }
          return Math.floor(p % self._domainType.day.row(d));
        },
      },
      format: {
        date: '%A %B %-e, %Y',
        legend: '%e %b',
        connector: 'on',
      },
      extractUnit: function (d) {
        return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
      },
    },
    week: {
      name: 'week',
      level: 40,
      maxItemNumber: 54,
      defaultColumnNumber: function (d) {
        d = new Date(d);
        switch (self.options.domain) {
          case 'year':
            return self._domainType.week.maxItemNumber;
          case 'month':
            return self.options.domainDynamicDimension
              ? self.getWeekNumber(
                  new Date(d.getFullYear(), d.getMonth() + 1, 0),
                ) - self.getWeekNumber(d)
              : 5;
        }
      },
      defaultRowNumber: 1,
      row: function (d) {
        return self.getSubDomainRowNumber(d);
      },
      column: function (d) {
        return self.getSubDomainColumnNumber(d);
      },
      position: {
        x: function (d) {
          switch (self.options.domain) {
            case 'year':
              return Math.floor(
                self.getWeekNumber(d) / self._domainType.week.row(d),
              );
            case 'month':
              return Math.floor(
                self.getMonthWeekNumber(d) / self._domainType.week.row(d),
              );
          }
        },
        y: function (d) {
          return self.getWeekNumber(d) % self._domainType.week.row(d);
        },
      },
      format: {
        date: '%B Week #%W',
        legend: '%B Week #%W',
        connector: 'in',
      },
      extractUnit: function (d) {
        var dt = new Date(d.getFullYear(), d.getMonth(), d.getDate());
        // According to ISO-8601, week number computation are based on week starting on Monday
        var weekDay = dt.getDay() - (self.options.weekStartOnMonday ? 1 : 0);
        if (weekDay < 0) {
          weekDay = 6;
        }
        dt.setDate(dt.getDate() - weekDay);
        return dt.getTime();
      },
    },
    month: {
      name: 'month',
      level: 50,
      maxItemNumber: 12,
      defaultColumnNumber: 12,
      defaultRowNumber: 1,
      row: function () {
        return self.getSubDomainRowNumber();
      },
      column: function () {
        return self.getSubDomainColumnNumber();
      },
      position: {
        x: function (d) {
          return Math.floor(d.getMonth() / self._domainType.month.row(d));
        },
        y: function (d) {
          return d.getMonth() % self._domainType.month.row(d);
        },
      },
      format: {
        date: '%B %Y',
        legend: '%B',
        connector: 'in',
      },
      extractUnit: function (d) {
        return new Date(d.getFullYear(), d.getMonth()).getTime();
      },
    },
    year: {
      name: 'year',
      level: 60,
      row: function () {
        return self.options.rowLimit || 1;
      },
      column: function () {
        return self.options.colLimit || 1;
      },
      position: {
        x: function () {
          return 1;
        },
        y: function () {
          return 1;
        },
      },
      format: {
        date: '%Y',
        legend: '%Y',
        connector: 'in',
      },
      extractUnit: function (d) {
        return new Date(d.getFullYear()).getTime();
      },
    },
  };

  for (var type in this._domainType) {
    if (this._domainType.hasOwnProperty(type)) {
      var d = this._domainType[type];
      this._domainType['x_' + type] = {
        name: 'x_' + type,
        level: d.type,
        maxItemNumber: d.maxItemNumber,
        defaultRowNumber: d.defaultRowNumber,
        defaultColumnNumber: d.defaultColumnNumber,
        row: d.column,
        column: d.row,
        position: {
          x: d.position.y,
          y: d.position.x,
        },
        format: d.format,
        extractUnit: d.extractUnit,
      };
    }
  }

  // Record the address of the last inserted domain when browsing
  this.lastInsertedSvg = null;

  this._completed = false;

  // Record all the valid domains
  // Each domain value is a timestamp in milliseconds
  this._domains = d3.map();

  this.graphDim = {
    width: 0,
    height: 0,
  };

  this.legendDim = {
    width: 0,
    height: 0,
  };

  this.NAVIGATE_LEFT = 1;
  this.NAVIGATE_RIGHT = 2;

  // Various update mode when using the update() API
  this.RESET_ALL_ON_UPDATE = 0;
  this.RESET_SINGLE_ON_UPDATE = 1;
  this.APPEND_ON_UPDATE = 2;

  this.DEFAULT_LEGEND_MARGIN = 10;

  this.root = null;
  this.tooltip = null;

  this._maxDomainReached = false;
  this._minDomainReached = false;

  this.domainPosition = new DomainPosition();
  this.Legend = null;
  this.legendScale = null;

  // List of domains that are skipped because of DST
  // All times belonging to these domains should be re-assigned to the previous domain
  this.DSTDomain = [];

  /**
   * Display the graph for the first time
   * @return bool True if the calendar is created
   */
  this._init = function () {
    self
      .getDomain(self.options.start)
      .map(function (d) {
        return d.getTime();
      })
      .map(function (d) {
        self._domains.set(
          d,
          self.getSubDomain(d).map(function (d) {
            return {
              t: self._domainType[self.options.subDomain].extractUnit(d),
              v: null,
            };
          }),
        );
      });

    self.root = d3
      .select(self.options.itemSelector)
      .append('svg')
      .attr('class', 'cal-heatmap-container');

    self.root.attr('x', 0).attr('y', 0).append('svg').attr('class', 'graph');

    self.Legend = new Legend(self);

    if (self.options.paintOnLoad) {
      _initCalendar();
    }
    self.root.call(self.tip);
    self.root.call(self.legendTip);

    return true;
  };

  function _initCalendar() {
    self.verticalDomainLabel =
      self.options.label.position === 'top' ||
      self.options.label.position === 'bottom';

    self.domainVerticalLabelHeight =
      self.options.label.height === null
        ? Math.max(25, self.options.cellSize * 2)
        : self.options.label.height;
    self.domainHorizontalLabelWidth = 0;

    if (
      self.options.domainLabelFormat === '' &&
      self.options.label.height === null
    ) {
      self.domainVerticalLabelHeight = 0;
    }

    if (!self.verticalDomainLabel) {
      self.domainVerticalLabelHeight = 0;
      self.domainHorizontalLabelWidth = self.options.label.width;
    }

    self.paint();

    // =========================================================================//
    // ATTACHING DOMAIN NAVIGATION EVENT                    //
    // =========================================================================//
    if (self.options.nextSelector !== false) {
      d3.select(self.options.nextSelector).on(
        'click.' + self.options.itemNamespace,
        function () {
          d3.event.preventDefault();
          return self.loadNextDomain(1);
        },
      );
    }

    if (self.options.previousSelector !== false) {
      d3.select(self.options.previousSelector).on(
        'click.' + self.options.itemNamespace,
        function () {
          d3.event.preventDefault();
          return self.loadPreviousDomain(1);
        },
      );
    }

    self.Legend.redraw(
      self.graphDim.width -
        self.options.domainGutter -
        self.options.cellPadding,
    );
    self.afterLoad();

    var domains = self.getDomainKeys();

    // Fill the graph with some datas
    if (self.options.loadOnInit) {
      self.getDatas(
        self.options.data,
        new Date(domains[0]),
        self.getSubDomain(domains[domains.length - 1]).pop(),
        function () {
          self.fill();
          self.onComplete();
        },
      );
    } else {
      self.onComplete();
    }

    self.checkIfMinDomainIsReached(domains[0]);
    self.checkIfMaxDomainIsReached(self.getNextDomain().getTime());
  }

  // Return the width of the domain block, without the domain gutter
  // @param int d Domain start timestamp
  function w(d, outer) {
    var width =
      self.options.cellSize *
        self._domainType[self.options.subDomain].column(d) +
      self.options.cellPadding *
        self._domainType[self.options.subDomain].column(d);
    if (arguments.length === 2 && outer === true) {
      return (width +=
        self.domainHorizontalLabelWidth +
        self.options.domainGutter +
        self.options.domainMargin[1] +
        self.options.domainMargin[3]);
    }
    return width;
  }

  // Return the height of the domain block, without the domain gutter
  function h(d, outer) {
    var height =
      self.options.cellSize * self._domainType[self.options.subDomain].row(d) +
      self.options.cellPadding *
        self._domainType[self.options.subDomain].row(d);
    if (arguments.length === 2 && outer === true) {
      height +=
        self.options.domainGutter +
        self.domainVerticalLabelHeight +
        self.options.domainMargin[0] +
        self.options.domainMargin[2];
    }
    return height;
  }

  /**
   *
   *
   * @param int navigationDir
   */
  this.paint = function (navigationDir) {
    var options = self.options;

    if (arguments.length === 0) {
      navigationDir = false;
    }

    // Painting all the domains
    var domainSvg = self.root
      .select('.graph')
      .selectAll('.graph-domain')
      .data(
        function () {
          var data = self.getDomainKeys();
          return navigationDir === self.NAVIGATE_LEFT ? data.reverse() : data;
        },
        function (d) {
          return d;
        },
      );
    var enteringDomainDim = 0;
    var exitingDomainDim = 0;

    // =========================================================================//
    // PAINTING DOMAIN                              //
    // =========================================================================//

    var svg = domainSvg
      .enter()
      .append('svg')
      .attr('width', function (d) {
        return w(d, true);
      })
      .attr('height', function (d) {
        return h(d, true);
      })
      .attr('x', function (d) {
        if (options.verticalOrientation) {
          self.graphDim.width = Math.max(self.graphDim.width, w(d, true));
          return 0;
        } else {
          return getDomainPosition(d, self.graphDim, 'width', w(d, true));
        }
      })
      .attr('y', function (d) {
        if (options.verticalOrientation) {
          return getDomainPosition(d, self.graphDim, 'height', h(d, true));
        } else {
          self.graphDim.height = Math.max(self.graphDim.height, h(d, true));
          return 0;
        }
      })
      .attr('class', function (d) {
        var classname = 'graph-domain';
        var date = new Date(d);
        switch (options.domain) {
          case 'hour':
            classname += ' h_' + date.getHours();
          /* falls through */
          case 'day':
            classname += ' d_' + date.getDate() + ' dy_' + date.getDay();
          /* falls through */
          case 'week':
            classname += ' w_' + self.getWeekNumber(date);
          /* falls through */
          case 'month':
            classname += ' m_' + (date.getMonth() + 1);
          /* falls through */
          case 'year':
            classname += ' y_' + date.getFullYear();
        }
        return classname;
      });
    self.lastInsertedSvg = svg;

    function getDomainPosition(domainIndex, graphDim, axis, domainDim) {
      var tmp = 0;
      switch (navigationDir) {
        case false:
          tmp = graphDim[axis];

          graphDim[axis] += domainDim;
          self.domainPosition.setPosition(domainIndex, tmp);
          return tmp;

        case self.NAVIGATE_RIGHT:
          self.domainPosition.setPosition(domainIndex, graphDim[axis]);

          enteringDomainDim = domainDim;
          exitingDomainDim = self.domainPosition.getPositionFromIndex(1);

          self.domainPosition.shiftRightBy(exitingDomainDim);
          return graphDim[axis];

        case self.NAVIGATE_LEFT:
          tmp = -domainDim;

          enteringDomainDim = -tmp;
          exitingDomainDim = graphDim[axis] - self.domainPosition.getLast();

          self.domainPosition.setPosition(domainIndex, tmp);
          self.domainPosition.shiftLeftBy(enteringDomainDim);
          return tmp;
      }
    }

    svg
      .append('rect')
      .attr('width', function (d) {
        return w(d, true) - options.domainGutter - options.cellPadding;
      })
      .attr('height', function (d) {
        return h(d, true) - options.domainGutter - options.cellPadding;
      })
      .attr('class', 'domain-background');

    // =========================================================================//
    // PAINTING SUBDOMAINS                            //
    // =========================================================================//
    var subDomainSvgGroup = svg
      .append('svg')
      .attr('x', function () {
        if (options.label.position === 'left') {
          return self.domainHorizontalLabelWidth + options.domainMargin[3];
        } else {
          return options.domainMargin[3];
        }
      })
      .attr('y', function () {
        if (options.label.position === 'top') {
          return self.domainVerticalLabelHeight + options.domainMargin[0];
        } else {
          return options.domainMargin[0];
        }
      })
      .attr('class', 'graph-subdomain-group');
    var rect = subDomainSvgGroup
      .selectAll('g')
      .data(function (d) {
        return self._domains.get(d);
      })
      .enter()
      .append('g');
    rect
      .append('rect')
      .attr('class', function (d) {
        return (
          'graph-rect' +
          self.getHighlightClassName(d.t) +
          (options.onClick !== null ? ' hover_cursor' : '')
        );
      })
      .attr('width', options.cellSize)
      .attr('height', options.cellSize)
      .attr('x', function (d) {
        return self.positionSubDomainX(d.t);
      })
      .attr('y', function (d) {
        return self.positionSubDomainY(d.t);
      })
      .on('click', function (d) {
        if (options.onClick !== null) {
          return self.onClick(new Date(d.t), d.v);
        }
      })
      .call(function (selection) {
        if (options.cellRadius > 0) {
          selection
            .attr('rx', options.cellRadius)
            .attr('ry', options.cellRadius);
        }

        if (
          self.legendScale !== null &&
          options.legendColors !== null &&
          options.legendColors.hasOwnProperty('base')
        ) {
          selection.attr('fill', options.legendColors.base);
        }

        if (options.tooltip) {
          selection
            .on('mouseover', function (d) {
              self.tip.show(d, this);
            })
            .on('mouseout', function () {
              self.tip.hide(d);
            });
        }
      });

    // Appending a title to each subdomain
    if (!options.tooltip) {
      rect.append('title').text(function (d) {
        return self.formatDate(new Date(d.t), options.subDomainDateFormat);
      });
    }

    // =========================================================================//
    // PAINTING LABEL                              //
    // =========================================================================//
    if (options.domainLabelFormat !== '') {
      svg
        .append('text')
        .attr('class', 'graph-label')
        .attr('y', function (d) {
          var y = options.domainMargin[0];
          switch (options.label.position) {
            case 'top':
              y += self.domainVerticalLabelHeight / 2;
              break;
            case 'bottom':
              y += h(d) + self.domainVerticalLabelHeight / 2;
          }

          return (
            y +
            options.label.offset.y *
              ((options.label.rotate === 'right' &&
                options.label.position === 'right') ||
              (options.label.rotate === 'left' &&
                options.label.position === 'left')
                ? -1
                : 1)
          );
        })
        .attr('x', function (d) {
          var x = options.domainMargin[3];
          switch (options.label.position) {
            case 'right':
              x += w(d);
              break;
            case 'bottom':
            case 'top':
              x += w(d) / 2;
          }

          if (options.label.align === 'right') {
            return (
              x +
              self.domainHorizontalLabelWidth -
              options.label.offset.x *
                (options.label.rotate === 'right' ? -1 : 1)
            );
          }
          return x + options.label.offset.x;
        })
        .attr('text-anchor', function () {
          switch (options.label.align) {
            case 'start':
            case 'left':
              return 'start';
            case 'end':
            case 'right':
              return 'end';
            default:
              return 'middle';
          }
        })
        .attr('dominant-baseline', function () {
          return self.verticalDomainLabel ? 'middle' : 'top';
        })
        .text(function (d) {
          return self.formatDate(new Date(d), options.domainLabelFormat);
        })
        .call(domainRotate);
    }

    function domainRotate(selection) {
      switch (options.label.rotate) {
        case 'right':
          selection.attr('transform', function (d) {
            var s = 'rotate(90), ';
            switch (options.label.position) {
              case 'right':
                s += 'translate(-' + w(d) + ' , -' + w(d) + ')';
                break;
              case 'left':
                s += 'translate(0, -' + self.domainHorizontalLabelWidth + ')';
                break;
            }

            return s;
          });
          break;
        case 'left':
          selection.attr('transform', function (d) {
            var s = 'rotate(270), ';
            switch (options.label.position) {
              case 'right':
                s +=
                  'translate(-' +
                  (w(d) + self.domainHorizontalLabelWidth) +
                  ' , ' +
                  w(d) +
                  ')';
                break;
              case 'left':
                s +=
                  'translate(-' +
                  self.domainHorizontalLabelWidth +
                  ' , ' +
                  self.domainHorizontalLabelWidth +
                  ')';
                break;
            }

            return s;
          });
          break;
      }
    }

    // =========================================================================//
    // PAINTING DOMAIN SUBDOMAIN CONTENT                    //
    // =========================================================================//
    if (options.subDomainTextFormat !== null) {
      rect
        .append('text')
        .attr('class', function (d) {
          return 'subdomain-text' + self.getHighlightClassName(d.t);
        })
        .attr('x', function (d) {
          return self.positionSubDomainX(d.t) + options.cellSize / 2;
        })
        .attr('y', function (d) {
          return self.positionSubDomainY(d.t) + options.cellSize / 2;
        })
        .attr('text-anchor', 'middle')
        .attr('dominant-baseline', 'central')
        .text(function (d) {
          return self.formatDate(new Date(d.t), options.subDomainTextFormat);
        });
    }

    // =========================================================================//
    // ANIMATION                                //
    // =========================================================================//

    if (navigationDir !== false) {
      domainSvg
        .transition()
        .duration(options.animationDuration)
        .attr('x', function (d) {
          return options.verticalOrientation
            ? 0
            : self.domainPosition.getPosition(d);
        })
        .attr('y', function (d) {
          return options.verticalOrientation
            ? self.domainPosition.getPosition(d)
            : 0;
        });
    }

    var tempWidth = self.graphDim.width;
    var tempHeight = self.graphDim.height;

    if (options.verticalOrientation) {
      self.graphDim.height += enteringDomainDim - exitingDomainDim;
    } else {
      self.graphDim.width += enteringDomainDim - exitingDomainDim;
    }

    // At the time of exit, domainsWidth and domainsHeight already automatically shifted
    domainSvg
      .exit()
      .transition()
      .duration(options.animationDuration)
      .attr('x', function (d) {
        if (options.verticalOrientation) {
          return 0;
        } else {
          switch (navigationDir) {
            case self.NAVIGATE_LEFT:
              return Math.min(self.graphDim.width, tempWidth);
            case self.NAVIGATE_RIGHT:
              return -w(d, true);
          }
        }
      })
      .attr('y', function (d) {
        if (options.verticalOrientation) {
          switch (navigationDir) {
            case self.NAVIGATE_LEFT:
              return Math.min(self.graphDim.height, tempHeight);
            case self.NAVIGATE_RIGHT:
              return -h(d, true);
          }
        } else {
          return 0;
        }
      })
      .remove();

    // Resize the root container
    self.resize();
  };
};

CalHeatMap.prototype = {
  /**
   * Validate and merge user settings with default settings
   *
   * @param  {object} settings User settings
   * @return {bool} False if settings contains error
   */
  /* jshint maxstatements:false */
  init: function (settings) {
    'use strict';

    var parent = this;

    var options = (parent.options = mergeRecursive(parent.options, settings));

    // Fatal errors
    // Stop script execution on error
    validateDomainType();
    validateSelector(options.itemSelector, false, 'itemSelector');

    if (parent.allowedDataType.indexOf(options.dataType) === -1) {
      throw new Error(
        "The data type '" + options.dataType + "' is not valid data type",
      );
    }

    if (d3.select(options.itemSelector)[0][0] === null) {
      throw new Error(
        "The node '" +
          options.itemSelector +
          "' specified in itemSelector does not exists",
      );
    }

    try {
      validateSelector(options.nextSelector, true, 'nextSelector');
      validateSelector(options.previousSelector, true, 'previousSelector');
    } catch (error) {
      console.log(error.message);
      return false;
    }

    // If other settings contains error, will fallback to default

    if (!settings.hasOwnProperty('subDomain')) {
      this.options.subDomain = getOptimalSubDomain(settings.domain);
    }

    if (
      typeof options.itemNamespace !== 'string' ||
      options.itemNamespace === ''
    ) {
      console.log(
        'itemNamespace can not be empty, falling back to cal-heatmap',
      );
      options.itemNamespace = 'cal-heatmap';
    }

    // Don't touch these settings
    var s = [
      'data',
      'onComplete',
      'onClick',
      'afterLoad',
      'afterLoadData',
      'afterLoadPreviousDomain',
      'afterLoadNextDomain',
      'afterUpdate',
    ];

    for (var k in s) {
      if (settings.hasOwnProperty(s[k])) {
        options[s[k]] = settings[s[k]];
      }
    }

    options.subDomainDateFormat =
      typeof options.subDomainDateFormat === 'string' ||
      typeof options.subDomainDateFormat === 'function'
        ? options.subDomainDateFormat
        : this._domainType[options.subDomain].format.date;
    options.domainLabelFormat =
      typeof options.domainLabelFormat === 'string' ||
      typeof options.domainLabelFormat === 'function'
        ? options.domainLabelFormat
        : this._domainType[options.domain].format.legend;
    options.subDomainTextFormat =
      (typeof options.subDomainTextFormat === 'string' &&
        options.subDomainTextFormat !== '') ||
      typeof options.subDomainTextFormat === 'function'
        ? options.subDomainTextFormat
        : null;
    options.domainMargin = expandMarginSetting(options.domainMargin);
    options.legendMargin = expandMarginSetting(options.legendMargin);
    options.highlight = parent.expandDateSetting(options.highlight);
    options.itemName = expandItemName(options.itemName);
    options.colLimit = parseColLimit(options.colLimit);
    options.rowLimit = parseRowLimit(options.rowLimit);
    if (!settings.hasOwnProperty('legendMargin')) {
      autoAddLegendMargin();
    }
    autoAlignLabel();

    /**
     * Validate that a queryString is valid
     *
     * @param  {Element|string|bool} selector   The queryString to test
     * @param  {bool}  canBeFalse  Whether false is an accepted and valid value
     * @param  {string} name    Name of the tested selector
     * @throws {Error}        If the selector is not valid
     * @return {bool}        True if the selector is a valid queryString
     */
    function validateSelector(selector, canBeFalse, name) {
      if (
        ((canBeFalse && selector === false) ||
          selector instanceof Element ||
          typeof selector === 'string') &&
        selector !== ''
      ) {
        return true;
      }
      throw new Error('The ' + name + ' is not valid');
    }

    /**
     * Return the optimal subDomain for the specified domain
     *
     * @param  {string} domain a domain name
     * @return {string}        the subDomain name
     */
    function getOptimalSubDomain(domain) {
      switch (domain) {
        case 'year':
          return 'month';
        case 'month':
          return 'day';
        case 'week':
          return 'day';
        case 'day':
          return 'hour';
        default:
          return 'min';
      }
    }

    /**
     * Ensure that the domain and subdomain are valid
     *
     * @throw {Error} when domain or subdomain are not valid
     * @return {bool} True if domain and subdomain are valid and compatible
     */
    function validateDomainType() {
      if (
        !parent._domainType.hasOwnProperty(options.domain) ||
        options.domain === 'min' ||
        options.domain.substring(0, 2) === 'x_'
      ) {
        throw new Error("The domain '" + options.domain + "' is not valid");
      }

      if (
        !parent._domainType.hasOwnProperty(options.subDomain) ||
        options.subDomain === 'year'
      ) {
        throw new Error(
          "The subDomain '" + options.subDomain + "' is not valid",
        );
      }

      if (
        parent._domainType[options.domain].level <=
        parent._domainType[options.subDomain].level
      ) {
        throw new Error(
          "'" +
            options.subDomain +
            "' is not a valid subDomain to '" +
            options.domain +
            "'",
        );
      }

      return true;
    }

    /**
     * Fine-tune the label alignement depending on its position
     *
     * @return void
     */
    function autoAlignLabel() {
      // Auto-align label, depending on it's position
      if (
        !settings.hasOwnProperty('label') ||
        (settings.hasOwnProperty('label') &&
          !settings.label.hasOwnProperty('align'))
      ) {
        switch (options.label.position) {
          case 'left':
            options.label.align = 'right';
            break;
          case 'right':
            options.label.align = 'left';
            break;
          default:
            options.label.align = 'center';
        }

        if (options.label.rotate === 'left') {
          options.label.align = 'right';
        } else if (options.label.rotate === 'right') {
          options.label.align = 'left';
        }
      }

      if (
        !settings.hasOwnProperty('label') ||
        (settings.hasOwnProperty('label') &&
          !settings.label.hasOwnProperty('offset'))
      ) {
        if (
          options.label.position === 'left' ||
          options.label.position === 'right'
        ) {
          options.label.offset = {
            x: 10,
            y: 15,
          };
        }
      }
    }

    /**
     * If not specified, add some margin around the legend depending on its position
     *
     * @return void
     */
    function autoAddLegendMargin() {
      switch (options.legendVerticalPosition) {
        case 'top':
          options.legendMargin[2] = parent.DEFAULT_LEGEND_MARGIN;
          break;
        case 'bottom':
          options.legendMargin[0] = parent.DEFAULT_LEGEND_MARGIN;
          break;
        case 'middle':
        case 'center':
          options.legendMargin[
            options.legendHorizontalPosition === 'right' ? 3 : 1
          ] = parent.DEFAULT_LEGEND_MARGIN;
      }
    }

    /**
     * Expand a number of an array of numbers to an usable 4 values array
     *
     * @param  {integer|array} value
     * @return {array}        array
     */
    function expandMarginSetting(value) {
      if (typeof value === 'number') {
        value = [value];
      }

      if (!Array.isArray(value)) {
        console.log('Margin only takes an integer or an array of integers');
        value = [0];
      }

      switch (value.length) {
        case 1:
          return [value[0], value[0], value[0], value[0]];
        case 2:
          return [value[0], value[1], value[0], value[1]];
        case 3:
          return [value[0], value[1], value[2], value[1]];
        case 4:
          return value;
        default:
          return value.slice(0, 4);
      }
    }

    /**
     * Convert a string to an array like [singular-form, plural-form]
     *
     * @param  {string|array} value Date to convert
     * @return {array}       An array like [singular-form, plural-form]
     */
    function expandItemName(value) {
      if (typeof value === 'string') {
        return [value, value + (value !== '' ? 's' : '')];
      }

      if (Array.isArray(value)) {
        if (value.length === 1) {
          return [value[0], value[0] + 's'];
        } else if (value.length > 2) {
          return value.slice(0, 2);
        }

        return value;
      }

      return ['item', 'items'];
    }

    function parseColLimit(value) {
      return value > 0 ? value : null;
    }

    function parseRowLimit(value) {
      if (value > 0 && options.colLimit > 0) {
        console.log(
          'colLimit and rowLimit are mutually exclusive, rowLimit will be ignored',
        );
        return null;
      }
      return value > 0 ? value : null;
    }

    return this._init();
  },

  /**
   * Convert a keyword or an array of keyword/date to an array of date objects
   *
   * @param  {string|array|Date} value Data to convert
   * @return {array}       An array of Dates
   */
  expandDateSetting: function (value) {
    'use strict';

    if (!Array.isArray(value)) {
      value = [value];
    }

    return value
      .map(function (data) {
        if (data === 'now') {
          return new Date();
        }
        if (data instanceof Date) {
          return data;
        }
        return false;
      })
      .filter(function (d) {
        return d !== false;
      });
  },

  /**
   * Fill the calendar by coloring the cells
   *
   * @param array svg An array of html node to apply the transformation to (optional)
   *                  It's used to limit the painting to only a subset of the calendar
   * @return void
   */
  fill: function (svg) {
    'use strict';

    var parent = this;
    var options = parent.options;

    if (arguments.length === 0) {
      svg = parent.root.selectAll('.graph-domain');
    }

    var rect = svg
      .selectAll('svg')
      .selectAll('g')
      .data(function (d) {
        return parent._domains.get(d);
      });
    /**
     * Colorize the cell via a style attribute if enabled
     */
    function addStyle(element) {
      if (parent.legendScale === null) {
        return false;
      }

      element.attr('fill', function (d) {
        if (
          d.v === null &&
          options.hasOwnProperty('considerMissingDataAsZero') &&
          !options.considerMissingDataAsZero
        ) {
          if (options.legendColors.hasOwnProperty('base')) {
            return options.legendColors.base;
          }
        }

        if (
          options.legendColors !== null &&
          options.legendColors.hasOwnProperty('empty') &&
          (d.v === 0 ||
            (d.v === null &&
              options.hasOwnProperty('considerMissingDataAsZero') &&
              options.considerMissingDataAsZero))
        ) {
          return options.legendColors.empty;
        }

        if (
          d.v < 0 &&
          options.legend[0] > 0 &&
          options.legendColors !== null &&
          options.legendColors.hasOwnProperty('overflow')
        ) {
          return options.legendColors.overflow;
        }

        return parent.legendScale(
          Math.min(d.v, options.legend[options.legend.length - 1]),
        );
      });
    }

    rect
      .transition()
      .duration(options.animationDuration)
      .select('rect')
      .attr('class', function (d) {
        var htmlClass = parent.getHighlightClassName(d.t).trim().split(' ');
        var pastDate = parent.dateIsLessThan(d.t, new Date());
        var sameDate = parent.dateIsEqual(d.t, new Date());

        if (
          parent.legendScale === null ||
          (d.v === null &&
            options.hasOwnProperty('considerMissingDataAsZero') &&
            !options.considerMissingDataAsZero &&
            !options.legendColors.hasOwnProperty('base'))
        ) {
          htmlClass.push('graph-rect');
        }

        if (sameDate) {
          htmlClass.push('now');
        } else if (!pastDate) {
          htmlClass.push('future');
        }

        if (d.v !== null) {
          htmlClass.push(
            parent.Legend.getClass(d.v, parent.legendScale === null),
          );
        } else if (options.considerMissingDataAsZero && pastDate) {
          htmlClass.push(
            parent.Legend.getClass(0, parent.legendScale === null),
          );
        }

        if (options.onClick !== null) {
          htmlClass.push('hover_cursor');
        }

        return htmlClass.join(' ');
      })
      .call(addStyle);

    rect
      .transition()
      .duration(options.animationDuration)
      .select('title')
      .text(function (d) {
        return parent.getSubDomainTitle(d);
      });

    function formatSubDomainText(element) {
      if (typeof options.subDomainTextFormat === 'function') {
        element.text(function (d) {
          return options.subDomainTextFormat(d.t, d.v);
        });
      }
    }

    /**
     * Change the subDomainText class if necessary
     * Also change the text, e.g when text is representing the value
     * instead of the date
     */
    rect
      .transition()
      .duration(options.animationDuration)
      .select('text')
      .attr('class', function (d) {
        return 'subdomain-text' + parent.getHighlightClassName(d.t);
      })
      .call(formatSubDomainText)
      .attr('fill', d => {
        if (!d.v) return '#000';
        const rgb = parent.legendScale(
          Math.min(d.v, options.legend[options.legend.length - 1]),
        );
        return getContrastingColor(rgb, 135);
      });
  },

  /**
   * Sprintf like function.
   * Replaces placeholders {0} in string with values from provided object.
   *
   * @param string formatted String containing placeholders.
   * @param object args Object with properties to replace placeholders in string.
   *
   * @return String
   */
  formatStringWithObject: function (formatted, args) {
    'use strict';
    for (var prop in args) {
      if (args.hasOwnProperty(prop)) {
        var regexp = new RegExp('\\{' + prop + '\\}', 'gi');
        formatted = formatted.replace(regexp, args[prop]);
      }
    }
    return formatted;
  },

  // =========================================================================//
  // EVENTS CALLBACK                              //
  // =========================================================================//

  /**
   * Helper method for triggering event callback
   *
   * @param  string  eventName       Name of the event to trigger
   * @param  array  successArgs     List of argument to pass to the callback
   * @param  boolean  skip      Whether to skip the event triggering
   * @return mixed  True when the triggering was skipped, false on error, else the callback function
   */
  triggerEvent: function (eventName, successArgs, skip) {
    'use strict';

    if ((arguments.length === 3 && skip) || this.options[eventName] === null) {
      return true;
    }

    if (typeof this.options[eventName] === 'function') {
      if (typeof successArgs === 'function') {
        successArgs = successArgs();
      }
      return this.options[eventName].apply(this, successArgs);
    } else {
      console.log('Provided callback for ' + eventName + ' is not a function.');
      return false;
    }
  },

  /**
   * Event triggered on a mouse click on a subDomain cell
   *
   * @param  Date    d    Date of the subdomain block
   * @param  int    itemNb  Number of items in that date
   */
  onClick: function (d, itemNb) {
    'use strict';

    return this.triggerEvent('onClick', [d, itemNb]);
  },

  /**
   * Event triggered after drawing the calendar, byt before filling it with data
   */
  afterLoad: function () {
    'use strict';

    return this.triggerEvent('afterLoad');
  },

  /**
   * Event triggered after completing drawing and filling the calendar
   */
  onComplete: function () {
    'use strict';

    var response = this.triggerEvent('onComplete', [], this._completed);
    this._completed = true;
    return response;
  },

  /**
   * Event triggered after shifting the calendar one domain back
   *
   * @param  Date    start  Domain start date
   * @param  Date    end    Domain end date
   */
  afterLoadPreviousDomain: function (start) {
    'use strict';

    var parent = this;
    return this.triggerEvent('afterLoadPreviousDomain', function () {
      var subDomain = parent.getSubDomain(start);
      return [subDomain.shift(), subDomain.pop()];
    });
  },

  /**
   * Event triggered after shifting the calendar one domain above
   *
   * @param  Date    start  Domain start date
   * @param  Date    end    Domain end date
   */
  afterLoadNextDomain: function (start) {
    'use strict';

    var parent = this;
    return this.triggerEvent('afterLoadNextDomain', function () {
      var subDomain = parent.getSubDomain(start);
      return [subDomain.shift(), subDomain.pop()];
    });
  },

  /**
   * Event triggered after loading the leftmost domain allowed by minDate
   *
   * @param  boolean  reached True if the leftmost domain was reached
   */
  onMinDomainReached: function (reached) {
    'use strict';

    this._minDomainReached = reached;
    return this.triggerEvent('onMinDomainReached', [reached]);
  },

  /**
   * Event triggered after loading the rightmost domain allowed by maxDate
   *
   * @param  boolean  reached True if the rightmost domain was reached
   */
  onMaxDomainReached: function (reached) {
    'use strict';

    this._maxDomainReached = reached;
    return this.triggerEvent('onMaxDomainReached', [reached]);
  },

  checkIfMinDomainIsReached: function (date, upperBound) {
    'use strict';

    if (this.minDomainIsReached(date)) {
      this.onMinDomainReached(true);
    }

    if (arguments.length === 2) {
      if (this._maxDomainReached && !this.maxDomainIsReached(upperBound)) {
        this.onMaxDomainReached(false);
      }
    }
  },

  checkIfMaxDomainIsReached: function (date, lowerBound) {
    'use strict';

    if (this.maxDomainIsReached(date)) {
      this.onMaxDomainReached(true);
    }

    if (arguments.length === 2) {
      if (this._minDomainReached && !this.minDomainIsReached(lowerBound)) {
        this.onMinDomainReached(false);
      }
    }
  },

  afterUpdate: function () {
    'use strict';

    return this.triggerEvent('afterUpdate');
  },

  // =========================================================================//
  // FORMATTER                                //
  // =========================================================================//

  formatNumber: d3.format(',g'),

  formatDate: function (d, format) {
    'use strict';

    if (arguments.length < 2) {
      format = 'title';
    }

    if (typeof format === 'function') {
      return format(d);
    } else {
      var f = d3.time.format(format);
      return f(d);
    }
  },

  getSubDomainTitle: function (d) {
    'use strict';

    if (d.v === null && !this.options.considerMissingDataAsZero) {
      return this.formatStringWithObject(
        this.options.subDomainTitleFormat.empty,
        {
          date: this.formatDate(
            new Date(d.t),
            this.options.subDomainDateFormat,
          ),
        },
      );
    } else {
      var value = d.v;
      // Consider null as 0
      if (value === null && this.options.considerMissingDataAsZero) {
        value = 0;
      }

      return this.formatStringWithObject(
        this.options.subDomainTitleFormat.filled,
        {
          count: this.formatNumber(value),
          name: this.options.itemName[value !== 1 ? 1 : 0],
          connector: this._domainType[this.options.subDomain].format.connector,
          date: this.formatDate(
            new Date(d.t),
            this.options.subDomainDateFormat,
          ),
        },
      );
    }
  },

  // =========================================================================//
  // DOMAIN NAVIGATION                            //
  // =========================================================================//

  /**
   * Shift the calendar one domain forward
   *
   * The new domain is loaded only if it's not beyond maxDate
   *
   * @param int n Number of domains to load
   * @return bool True if the next domain was loaded, else false
   */
  loadNextDomain: function (n) {
    'use strict';

    if (this._maxDomainReached || n === 0) {
      return false;
    }

    var bound = this.loadNewDomains(
      this.NAVIGATE_RIGHT,
      this.getDomain(this.getNextDomain(), n),
    );

    this.afterLoadNextDomain(bound.end);
    this.checkIfMaxDomainIsReached(this.getNextDomain().getTime(), bound.start);

    return true;
  },

  /**
   * Shift the calendar one domain backward
   *
   * The previous domain is loaded only if it's not beyond the minDate
   *
   * @param int n Number of domains to load
   * @return bool True if the previous domain was loaded, else false
   */
  loadPreviousDomain: function (n) {
    'use strict';

    if (this._minDomainReached || n === 0) {
      return false;
    }

    var bound = this.loadNewDomains(
      this.NAVIGATE_LEFT,
      this.getDomain(this.getDomainKeys()[0], -n).reverse(),
    );

    this.afterLoadPreviousDomain(bound.start);
    this.checkIfMinDomainIsReached(bound.start, bound.end);

    return true;
  },

  loadNewDomains: function (direction, newDomains) {
    'use strict';

    var parent = this;
    var backward = direction === this.NAVIGATE_LEFT;
    var i = -1;
    var total = newDomains.length;
    var domains = this.getDomainKeys();

    function buildSubDomain(d) {
      return {
        t: parent._domainType[parent.options.subDomain].extractUnit(d),
        v: null,
      };
    }

    // Remove out of bound domains from list of new domains to prepend
    while (++i < total) {
      if (backward && this.minDomainIsReached(newDomains[i])) {
        newDomains = newDomains.slice(0, i + 1);
        break;
      }
      if (!backward && this.maxDomainIsReached(newDomains[i])) {
        newDomains = newDomains.slice(0, i);
        break;
      }
    }

    newDomains = newDomains.slice(-this.options.range);

    for (i = 0, total = newDomains.length; i < total; i += 1) {
      this._domains.set(
        newDomains[i].getTime(),
        this.getSubDomain(newDomains[i]).map(buildSubDomain),
      );

      this._domains.remove(backward ? domains.pop() : domains.shift());
    }

    domains = this.getDomainKeys();

    if (backward) {
      newDomains = newDomains.reverse();
    }

    this.paint(direction);

    this.getDatas(
      this.options.data,
      newDomains[0],
      this.getSubDomain(newDomains[newDomains.length - 1]).pop(),
      function () {
        parent.fill(parent.lastInsertedSvg);
      },
    );

    return {
      start: newDomains[backward ? 0 : 1],
      end: domains[domains.length - 1],
    };
  },

  /**
   * Return whether a date is inside the scope determined by maxDate
   *
   * @param int datetimestamp The timestamp in ms to test
   * @return bool True if the specified date correspond to the calendar upper bound
   */
  maxDomainIsReached: function (datetimestamp) {
    'use strict';

    return (
      this.options.maxDate !== null &&
      this.options.maxDate.getTime() < datetimestamp
    );
  },

  /**
   * Return whether a date is inside the scope determined by minDate
   *
   * @param int datetimestamp The timestamp in ms to test
   * @return bool True if the specified date correspond to the calendar lower bound
   */
  minDomainIsReached: function (datetimestamp) {
    'use strict';

    return (
      this.options.minDate !== null &&
      this.options.minDate.getTime() >= datetimestamp
    );
  },

  /**
   * Return the list of the calendar's domain timestamp
   *
   * @return Array a sorted array of timestamp
   */
  getDomainKeys: function () {
    'use strict';

    return this._domains
      .keys()
      .map(function (d) {
        return parseInt(d, 10);
      })
      .sort(function (a, b) {
        return a - b;
      });
  },

  // =========================================================================//
  // POSITIONNING                                //
  // =========================================================================//

  positionSubDomainX: function (d) {
    'use strict';

    var index = this._domainType[this.options.subDomain].position.x(
      new Date(d),
    );
    return index * this.options.cellSize + index * this.options.cellPadding;
  },

  positionSubDomainY: function (d) {
    'use strict';

    var index = this._domainType[this.options.subDomain].position.y(
      new Date(d),
    );
    return index * this.options.cellSize + index * this.options.cellPadding;
  },

  getSubDomainColumnNumber: function (d) {
    'use strict';

    if (this.options.rowLimit > 0) {
      var i = this._domainType[this.options.subDomain].maxItemNumber;
      if (typeof i === 'function') {
        i = i(d);
      }
      return Math.ceil(i / this.options.rowLimit);
    }

    var j = this._domainType[this.options.subDomain].defaultColumnNumber;
    if (typeof j === 'function') {
      j = j(d);
    }
    return this.options.colLimit || j;
  },

  getSubDomainRowNumber: function (d) {
    'use strict';

    if (this.options.colLimit > 0) {
      var i = this._domainType[this.options.subDomain].maxItemNumber;
      if (typeof i === 'function') {
        i = i(d);
      }
      return Math.ceil(i / this.options.colLimit);
    }

    var j = this._domainType[this.options.subDomain].defaultRowNumber;
    if (typeof j === 'function') {
      j = j(d);
    }
    return this.options.rowLimit || j;
  },

  /**
   * Return a classname if the specified date should be highlighted
   *
   * @param  timestamp date Date of the current subDomain
   * @return String the highlight class
   */
  getHighlightClassName: function (d) {
    'use strict';

    d = new Date(d);

    if (this.options.highlight.length > 0) {
      for (var i in this.options.highlight) {
        if (this.dateIsEqual(this.options.highlight[i], d)) {
          return this.isNow(this.options.highlight[i])
            ? ' highlight-now'
            : ' highlight';
        }
      }
    }
    return '';
  },

  /**
   * Return whether the specified date is now,
   * according to the type of subdomain
   *
   * @param  Date d The date to compare
   * @return bool True if the date correspond to a subdomain cell
   */
  isNow: function (d) {
    'use strict';

    return this.dateIsEqual(d, new Date());
  },

  /**
   * Return whether 2 dates are equals
   * This function is subdomain-aware,
   * and dates comparison are dependent of the subdomain
   *
   * @param  Date dateA First date to compare
   * @param  Date dateB Secon date to compare
   * @return bool true if the 2 dates are equals
   */
  /* jshint maxcomplexity: false */
  dateIsEqual: function (dateA, dateB) {
    'use strict';

    if (!(dateA instanceof Date)) {
      dateA = new Date(dateA);
    }

    if (!(dateB instanceof Date)) {
      dateB = new Date(dateB);
    }

    switch (this.options.subDomain) {
      case 'x_min':
      case 'min':
        return (
          dateA.getFullYear() === dateB.getFullYear() &&
          dateA.getMonth() === dateB.getMonth() &&
          dateA.getDate() === dateB.getDate() &&
          dateA.getHours() === dateB.getHours() &&
          dateA.getMinutes() === dateB.getMinutes()
        );
      case 'x_hour':
      case 'hour':
        return (
          dateA.getFullYear() === dateB.getFullYear() &&
          dateA.getMonth() === dateB.getMonth() &&
          dateA.getDate() === dateB.getDate() &&
          dateA.getHours() === dateB.getHours()
        );
      case 'x_day':
      case 'day':
        return (
          dateA.getFullYear() === dateB.getFullYear() &&
          dateA.getMonth() === dateB.getMonth() &&
          dateA.getDate() === dateB.getDate()
        );
      case 'x_week':
      case 'week':
        return (
          dateA.getFullYear() === dateB.getFullYear() &&
          this.getWeekNumber(dateA) === this.getWeekNumber(dateB)
        );
      case 'x_month':
      case 'month':
        return (
          dateA.getFullYear() === dateB.getFullYear() &&
          dateA.getMonth() === dateB.getMonth()
        );
      default:
        return false;
    }
  },

  /**
   * Returns wether or not dateA is less than or equal to dateB. This function is subdomain aware.
   * Performs automatic conversion of values.
   * @param dateA may be a number or a Date
   * @param dateB may be a number or a Date
   * @returns {boolean}
   */
  dateIsLessThan: function (dateA, dateB) {
    'use strict';

    if (!(dateA instanceof Date)) {
      dateA = new Date(dateA);
    }

    if (!(dateB instanceof Date)) {
      dateB = new Date(dateB);
    }

    function normalizedMillis(date, subdomain) {
      switch (subdomain) {
        case 'x_min':
        case 'min':
          return new Date(
            date.getFullYear(),
            date.getMonth(),
            date.getDate(),
            date.getHours(),
            date.getMinutes(),
          ).getTime();
        case 'x_hour':
        case 'hour':
          return new Date(
            date.getFullYear(),
            date.getMonth(),
            date.getDate(),
            date.getHours(),
          ).getTime();
        case 'x_day':
        case 'day':
          return new Date(
            date.getFullYear(),
            date.getMonth(),
            date.getDate(),
          ).getTime();
        case 'x_week':
        case 'week':
        case 'x_month':
        case 'month':
          return new Date(date.getFullYear(), date.getMonth()).getTime();
        default:
          return date.getTime();
      }
    }

    return (
      normalizedMillis(dateA, this.options.subDomain) <
      normalizedMillis(dateB, this.options.subDomain)
    );
  },

  // =========================================================================//
  // DATE COMPUTATION                              //
  // =========================================================================//

  /**
   * Return the day of the year for the date
   * @param  Date
   * @return  int Day of the year [1,366]
   */
  getDayOfYear: d3.time.format('%j'),

  /**
   * Return the week number of the year
   * Monday as the first day of the week
   * @return int  Week number [0-53]
   */
  getWeekNumber: function (d) {
    'use strict';

    var f =
      this.options.weekStartOnMonday === true
        ? d3.time.format('%W')
        : d3.time.format('%U');
    return f(d);
  },

  /**
   * Return the week number, relative to its month
   *
   * @param  int|Date d Date or timestamp in milliseconds
   * @return int Week number, relative to the month [0-5]
   */
  getMonthWeekNumber: function (d) {
    'use strict';

    if (typeof d === 'number') {
      d = new Date(d);
    }

    var monthFirstWeekNumber = this.getWeekNumber(
      new Date(d.getFullYear(), d.getMonth()),
    );
    return this.getWeekNumber(d) - monthFirstWeekNumber - 1;
  },

  /**
   * Return the number of weeks in the dates' year
   *
   * @param  int|Date d Date or timestamp in milliseconds
   * @return int Number of weeks in the date's year
   */
  getWeekNumberInYear: function (d) {
    'use strict';

    if (typeof d === 'number') {
      d = new Date(d);
    }
  },

  /**
   * Return the number of days in the date's month
   *
   * @param  int|Date d Date or timestamp in milliseconds
   * @return int Number of days in the date's month
   */
  getDayCountInMonth: function (d) {
    'use strict';

    return this.getEndOfMonth(d).getDate();
  },

  /**
   * Return the number of days in the date's year
   *
   * @param  int|Date d Date or timestamp in milliseconds
   * @return int Number of days in the date's year
   */
  getDayCountInYear: function (d) {
    'use strict';

    if (typeof d === 'number') {
      d = new Date(d);
    }
    return new Date(d.getFullYear(), 1, 29).getMonth() === 1 ? 366 : 365;
  },

  /**
   * Get the weekday from a date
   *
   * Return the week day number (0-6) of a date,
   * depending on whether the week start on monday or sunday
   *
   * @param  Date d
   * @return int The week day number (0-6)
   */
  getWeekDay: function (d) {
    'use strict';

    if (this.options.weekStartOnMonday === false) {
      return d.getDay();
    }
    return d.getDay() === 0 ? 6 : d.getDay() - 1;
  },

  /**
   * Get the last day of the month
   * @param  Date|int  d  Date or timestamp in milliseconds
   * @return Date      Last day of the month
   */
  getEndOfMonth: function (d) {
    'use strict';

    if (typeof d === 'number') {
      d = new Date(d);
    }
    return new Date(d.getFullYear(), d.getMonth() + 1, 0);
  },

  /**
   *
   * @param  Date date
   * @param  int count
   * @param  string step
   * @return Date
   */
  jumpDate: function (date, count, step) {
    'use strict';

    var d = new Date(date);
    switch (step) {
      case 'hour':
        d.setHours(d.getHours() + count);
        break;
      case 'day':
        d.setHours(d.getHours() + count * 24);
        break;
      case 'week':
        d.setHours(d.getHours() + count * 24 * 7);
        break;
      case 'month':
        d.setMonth(d.getMonth() + count);
        break;
      case 'year':
        d.setFullYear(d.getFullYear() + count);
    }

    return new Date(d);
  },

  // =========================================================================//
  // DOMAIN COMPUTATION                            //
  // =========================================================================//

  /**
   * Return all the minutes between 2 dates
   *
   * @param  Date  d  date  A date
   * @param  int|date  range  Number of minutes in the range, or a stop date
   * @return array  An array of minutes
   */
  getMinuteDomain: function (d, range) {
    'use strict';

    var start = new Date(
      d.getFullYear(),
      d.getMonth(),
      d.getDate(),
      d.getHours(),
    );
    var stop = null;
    if (range instanceof Date) {
      stop = new Date(
        range.getFullYear(),
        range.getMonth(),
        range.getDate(),
        range.getHours(),
      );
    } else {
      stop = new Date(+start + range * 1000 * 60);
    }
    return d3.time.minutes(Math.min(start, stop), Math.max(start, stop));
  },

  /**
   * Return all the hours between 2 dates
   *
   * @param  Date  d  A date
   * @param  int|date  range  Number of hours in the range, or a stop date
   * @return array  An array of hours
   */
  getHourDomain: function (d, range) {
    'use strict';

    var start = new Date(
      d.getFullYear(),
      d.getMonth(),
      d.getDate(),
      d.getHours(),
    );
    var stop = null;
    if (range instanceof Date) {
      stop = new Date(
        range.getFullYear(),
        range.getMonth(),
        range.getDate(),
        range.getHours(),
      );
    } else {
      stop = new Date(start);
      stop.setHours(stop.getHours() + range);
    }

    var domains = d3.time.hours(Math.min(start, stop), Math.max(start, stop));

    // Passing from DST to standard time
    // If there are 25 hours, let's compress the duplicate hours
    var i = 0;
    var total = domains.length;
    for (i = 0; i < total; i += 1) {
      if (i > 0 && domains[i].getHours() === domains[i - 1].getHours()) {
        this.DSTDomain.push(domains[i].getTime());
        domains.splice(i, 1);
        break;
      }
    }

    // d3.time.hours is returning more hours than needed when changing
    // from DST to standard time, because there is really 2 hours between
    // 1am and 2am!
    if (typeof range === 'number' && domains.length > Math.abs(range)) {
      domains.splice(domains.length - 1, 1);
    }

    return domains;
  },

  /**
   * Return all the days between 2 dates
   *
   * @param  Date    d    A date
   * @param  int|date  range  Number of days in the range, or a stop date
   * @return array  An array of weeks
   */
  getDayDomain: function (d, range) {
    'use strict';

    var start = new Date(d.getFullYear(), d.getMonth(), d.getDate());
    var stop = null;
    if (range instanceof Date) {
      stop = new Date(range.getFullYear(), range.getMonth(), range.getDate());
    } else {
      stop = new Date(start);
      stop = new Date(stop.setDate(stop.getDate() + parseInt(range, 10)));
    }

    return d3.time.days(Math.min(start, stop), Math.max(start, stop));
  },

  /**
   * Return all the weeks between 2 dates
   *
   * @param  Date  d  A date
   * @param  int|date  range  Number of minutes in the range, or a stop date
   * @return array  An array of weeks
   */
  getWeekDomain: function (d, range) {
    'use strict';

    var weekStart;

    if (this.options.weekStartOnMonday === false) {
      weekStart = new Date(
        d.getFullYear(),
        d.getMonth(),
        d.getDate() - d.getDay(),
      );
    } else {
      if (d.getDay() === 1) {
        weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate());
      } else if (d.getDay() === 0) {
        weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate());
        weekStart.setDate(weekStart.getDate() - 6);
      } else {
        weekStart = new Date(
          d.getFullYear(),
          d.getMonth(),
          d.getDate() - d.getDay() + 1,
        );
      }
    }

    var endDate = new Date(weekStart);

    var stop = range;
    if (typeof range !== 'object') {
      stop = new Date(endDate.setDate(endDate.getDate() + range * 7));
    }

    return this.options.weekStartOnMonday === true
      ? d3.time.mondays(Math.min(weekStart, stop), Math.max(weekStart, stop))
      : d3.time.sundays(Math.min(weekStart, stop), Math.max(weekStart, stop));
  },

  /**
   * Return all the months between 2 dates
   *
   * @param  Date    d    A date
   * @param  int|date  range  Number of months in the range, or a stop date
   * @return array  An array of months
   */
  getMonthDomain: function (d, range) {
    'use strict';

    var start = new Date(d.getFullYear(), d.getMonth());
    var stop = null;
    if (range instanceof Date) {
      stop = new Date(range.getFullYear(), range.getMonth());
    } else {
      stop = new Date(start);
      stop = stop.setMonth(stop.getMonth() + range);
    }

    return d3.time.months(Math.min(start, stop), Math.max(start, stop));
  },

  /**
   * Return all the years between 2 dates
   *
   * @param  Date  d  date  A date
   * @param  int|date  range  Number of minutes in the range, or a stop date
   * @return array  An array of hours
   */
  getYearDomain: function (d, range) {
    'use strict';

    var start = new Date(d.getFullYear(), 0);
    var stop = null;
    if (range instanceof Date) {
      stop = new Date(range.getFullYear(), 0);
    } else {
      stop = new Date(d.getFullYear() + range, 0);
    }

    return d3.time.years(Math.min(start, stop), Math.max(start, stop));
  },

  /**
   * Get an array of domain start dates
   *
   * @param  int|Date date A random date included in the wanted domain
   * @param  int|Date range Number of dates to get, or a stop date
   * @return Array of dates
   */
  getDomain: function (date, range) {
    'use strict';

    if (typeof date === 'number') {
      date = new Date(date);
    }

    if (arguments.length < 2) {
      range = this.options.range;
    }

    switch (this.options.domain) {
      case 'hour':
        var domains = this.getHourDomain(date, range);

        // Case where an hour is missing, when passing from standard time to DST
        // Missing hour is perfectly acceptabl in subDomain, but not in domains
        if (typeof range === 'number' && domains.length < range) {
          if (range > 0) {
            domains.push(this.getHourDomain(domains[domains.length - 1], 2)[1]);
          } else {
            domains.shift(this.getHourDomain(domains[0], -2)[0]);
          }
        }
        return domains;
      case 'day':
        return this.getDayDomain(date, range);
      case 'week':
        return this.getWeekDomain(date, range);
      case 'month':
        return this.getMonthDomain(date, range);
      case 'year':
        return this.getYearDomain(date, range);
    }
  },

  /* jshint maxcomplexity: false */
  getSubDomain: function (date) {
    'use strict';

    if (typeof date === 'number') {
      date = new Date(date);
    }

    var parent = this;

    /**
     * @return int
     */
    var computeDaySubDomainSize = function (date, domain) {
      switch (domain) {
        case 'year':
          return parent.getDayCountInYear(date);
        case 'month':
          return parent.getDayCountInMonth(date);
        case 'week':
          return 7;
      }
    };

    /**
     * @return int
     */
    var computeMinSubDomainSize = function (date, domain) {
      switch (domain) {
        case 'hour':
          return 60;
        case 'day':
          return 60 * 24;
        case 'week':
          return 60 * 24 * 7;
      }
    };

    /**
     * @return int
     */
    var computeHourSubDomainSize = function (date, domain) {
      switch (domain) {
        case 'day':
          return 24;
        case 'week':
          return 168;
        case 'month':
          return parent.getDayCountInMonth(date) * 24;
      }
    };

    /**
     * @return int
     */
    var computeWeekSubDomainSize = function (date, domain) {
      if (domain === 'month') {
        var endOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0);
        var endWeekNb = parent.getWeekNumber(endOfMonth);
        var startWeekNb = parent.getWeekNumber(
          new Date(date.getFullYear(), date.getMonth()),
        );

        if (startWeekNb > endWeekNb) {
          startWeekNb = 0;
          endWeekNb += 1;
        }

        return endWeekNb - startWeekNb + 1;
      } else if (domain === 'year') {
        return parent.getWeekNumber(new Date(date.getFullYear(), 11, 31));
      }
    };

    switch (this.options.subDomain) {
      case 'x_min':
      case 'min':
        return this.getMinuteDomain(
          date,
          computeMinSubDomainSize(date, this.options.domain),
        );
      case 'x_hour':
      case 'hour':
        return this.getHourDomain(
          date,
          computeHourSubDomainSize(date, this.options.domain),
        );
      case 'x_day':
      case 'day':
        return this.getDayDomain(
          date,
          computeDaySubDomainSize(date, this.options.domain),
        );
      case 'x_week':
      case 'week':
        return this.getWeekDomain(
          date,
          computeWeekSubDomainSize(date, this.options.domain),
        );
      case 'x_month':
      case 'month':
        return this.getMonthDomain(date, 12);
    }
  },

  /**
   * Get the n-th next domain after the calendar newest (rightmost) domain
   * @param  int n
   * @return Date The start date of the wanted domain
   */
  getNextDomain: function (n) {
    'use strict';

    if (arguments.length === 0) {
      n = 1;
    }
    return this.getDomain(
      this.jumpDate(this.getDomainKeys().pop(), n, this.options.domain),
      1,
    )[0];
  },

  /**
   * Get the n-th domain before the calendar oldest (leftmost) domain
   * @param  int n
   * @return Date The start date of the wanted domain
   */
  getPreviousDomain: function (n) {
    'use strict';

    if (arguments.length === 0) {
      n = 1;
    }
    return this.getDomain(
      this.jumpDate(this.getDomainKeys().shift(), -n, this.options.domain),
      1,
    )[0];
  },

  // =========================================================================//
  // DATAS                                  //
  // =========================================================================//

  /**
   * Fetch and interpret data from the datasource
   *
   * @param string|object source
   * @param Date startDate
   * @param Date endDate
   * @param function callback
   * @param function|boolean afterLoad function used to convert the data into a json object. Use true to use the afterLoad callback
   * @param updateMode
   *
   * @return mixed
   * - True if there are no data to load
   * - False if data are loaded asynchronously
   */
  getDatas: function (
    source,
    startDate,
    endDate,
    callback,
    afterLoad,
    updateMode,
  ) {
    'use strict';

    var self = this;
    if (arguments.length < 5) {
      afterLoad = true;
    }
    if (arguments.length < 6) {
      updateMode = this.APPEND_ON_UPDATE;
    }
    var _callback = function (error, data) {
      if (afterLoad !== false) {
        if (typeof afterLoad === 'function') {
          data = afterLoad(data);
        } else if (typeof self.options.afterLoadData === 'function') {
          data = self.options.afterLoadData(data);
        } else {
          console.log('Provided callback for afterLoadData is not a function.');
        }
      } else if (
        self.options.dataType === 'csv' ||
        self.options.dataType === 'tsv'
      ) {
        data = this.interpretCSV(data);
      }
      self.parseDatas(data, updateMode, startDate, endDate);
      if (typeof callback === 'function') {
        callback();
      }
    };

    switch (typeof source) {
      case 'string':
        if (source === '') {
          _callback(null, {});
          return true;
        } else {
          var url = this.parseURI(source, startDate, endDate);
          var requestType = 'GET';
          if (self.options.dataPostPayload !== null) {
            requestType = 'POST';
          }
          var payload = null;
          if (self.options.dataPostPayload !== null) {
            payload = this.parseURI(
              self.options.dataPostPayload,
              startDate,
              endDate,
            );
          }

          var xhr = null;
          switch (this.options.dataType) {
            case 'json':
              xhr = d3.json(url);
              break;
            case 'csv':
              xhr = d3.csv(url);
              break;
            case 'tsv':
              xhr = d3.tsv(url);
              break;
            case 'txt':
              xhr = d3.text(url, 'text/plain');
              break;
          }

          // jshint maxdepth:5
          if (self.options.dataRequestHeaders !== null) {
            for (var header in self.options.dataRequestHeaders) {
              if (self.options.dataRequestHeaders.hasOwnProperty(header)) {
                xhr.header(header, self.options.dataRequestHeaders[header]);
              }
            }
          }

          xhr.send(requestType, payload, _callback);
        }
        return false;
      case 'object':
        if (source === Object(source)) {
          _callback(null, source);
          return false;
        }
      /* falls through */
      default:
        _callback(null, {});
        return true;
    }
  },

  /**
   * Populate the calendar internal data
   *
   * @param object data
   * @param constant updateMode
   * @param Date startDate
   * @param Date endDate
   *
   * @return void
   */
  parseDatas: function (data, updateMode, startDate, endDate) {
    'use strict';

    if (updateMode === this.RESET_ALL_ON_UPDATE) {
      this._domains.forEach(function (key, value) {
        value.forEach(function (element, index, array) {
          array[index].v = null;
        });
      });
    }

    var temp = {};

    var extractTime = function (d) {
      return d.t;
    };

    /*jshint forin:false */
    for (var d in data) {
      var date = new Date(d * 1000);
      var domainUnit = this.getDomain(date)[0].getTime();
      // The current data belongs to a domain that was compressed
      // Compress the data for the two duplicate hours into the same hour
      if (this.DSTDomain.indexOf(domainUnit) >= 0) {
        // Re-assign all data to the first or the second duplicate hours
        // depending on which is visible
        if (this._domains.has(domainUnit - 3600 * 1000)) {
          domainUnit -= 3600 * 1000;
        }
      }

      // Skip if data is not relevant to current domain
      if (
        isNaN(d) ||
        !data.hasOwnProperty(d) ||
        !this._domains.has(domainUnit) ||
        !(domainUnit >= +startDate && domainUnit < +endDate)
      ) {
        continue;
      }

      var subDomainsData = this._domains.get(domainUnit);

      if (!temp.hasOwnProperty(domainUnit)) {
        temp[domainUnit] = subDomainsData.map(extractTime);
      }

      var index = temp[domainUnit].indexOf(
        this._domainType[this.options.subDomain].extractUnit(date),
      );

      if (updateMode === this.RESET_SINGLE_ON_UPDATE) {
        subDomainsData[index].v = data[d];
      } else {
        if (!isNaN(subDomainsData[index].v)) {
          subDomainsData[index].v += data[d];
        } else {
          subDomainsData[index].v = data[d];
        }
      }
    }
  },

  parseURI: function (str, startDate, endDate) {
    'use strict';

    // Use a timestamp in seconds
    str = str.replace(/\{\{t:start\}\}/g, startDate.getTime() / 1000);
    str = str.replace(/\{\{t:end\}\}/g, endDate.getTime() / 1000);

    // Use a string date, following the ISO-8601
    str = str.replace(/\{\{d:start\}\}/g, startDate.toISOString());
    str = str.replace(/\{\{d:end\}\}/g, endDate.toISOString());

    return str;
  },

  interpretCSV: function (data) {
    'use strict';

    var d = {};
    var keys = Object.keys(data[0]);
    var i, total;
    for (i = 0, total = data.length; i < total; i += 1) {
      d[data[i][keys[0]]] = +data[i][keys[1]];
    }
    return d;
  },

  /**
   * Handle the calendar layout and dimension
   *
   * Expand and shrink the container depending on its children dimension
   * Also rearrange the children position depending on their dimension,
   * and the legend position
   *
   * @return void
   */
  resize: function () {
    'use strict';

    var parent = this;
    var options = parent.options;
    var legendWidth = options.displayLegend
      ? parent.Legend.getDim('width') +
        options.legendMargin[1] +
        options.legendMargin[3]
      : 0;
    var legendHeight = options.displayLegend
      ? parent.Legend.getDim('height') +
        options.legendMargin[0] +
        options.legendMargin[2]
      : 0;

    var graphWidth =
      parent.graphDim.width - options.domainGutter - options.cellPadding;
    var graphHeight =
      parent.graphDim.height - options.domainGutter - options.cellPadding;

    this.root
      .transition()
      .duration(options.animationDuration)
      .attr('width', function () {
        if (
          options.legendVerticalPosition === 'middle' ||
          options.legendVerticalPosition === 'center'
        ) {
          return graphWidth + legendWidth;
        }
        return Math.max(graphWidth, legendWidth);
      })
      .attr('height', function () {
        if (
          options.legendVerticalPosition === 'middle' ||
          options.legendVerticalPosition === 'center'
        ) {
          return Math.max(graphHeight, legendHeight);
        }
        return graphHeight + legendHeight;
      });

    this.root
      .select('.graph')
      .transition()
      .duration(options.animationDuration)
      .attr('y', function () {
        if (options.legendVerticalPosition === 'top') {
          return legendHeight;
        }
        return 0;
      })
      .attr('x', function () {
        if (
          (options.legendVerticalPosition === 'middle' ||
            options.legendVerticalPosition === 'center') &&
          options.legendHorizontalPosition === 'left'
        ) {
          return legendWidth;
        }
        return 0;
      });
  },

  // =========================================================================//
  // PUBLIC API                                //
  // =========================================================================//

  /**
   * Shift the calendar forward
   */
  next: function (n) {
    'use strict';

    if (arguments.length === 0) {
      n = 1;
    }
    return this.loadNextDomain(n);
  },

  /**
   * Shift the calendar backward
   */
  previous: function (n) {
    'use strict';

    if (arguments.length === 0) {
      n = 1;
    }
    return this.loadPreviousDomain(n);
  },

  /**
   * Jump directly to a specific date
   *
   * JumpTo will scroll the calendar until the wanted domain with the specified
   * date is visible. Unless you set reset to true, the wanted domain
   * will not necessarily be the first (leftmost) domain of the calendar.
   *
   * @param Date date Jump to the domain containing that date
   * @param bool reset Whether the wanted domain should be the first domain of the calendar
   * @param bool True of the calendar was scrolled
   */
  jumpTo: function (date, reset) {
    'use strict';

    if (arguments.length < 2) {
      reset = false;
    }
    var domains = this.getDomainKeys();
    var firstDomain = domains[0];
    var lastDomain = domains[domains.length - 1];

    if (date < firstDomain) {
      return this.loadPreviousDomain(this.getDomain(firstDomain, date).length);
    } else {
      if (reset) {
        return this.loadNextDomain(this.getDomain(firstDomain, date).length);
      }

      if (date > lastDomain) {
        return this.loadNextDomain(this.getDomain(lastDomain, date).length);
      }
    }

    return false;
  },

  /**
   * Navigate back to the start date
   *
   * @since  3.3.8
   * @return void
   */
  rewind: function () {
    'use strict';

    this.jumpTo(this.options.start, true);
  },

  /**
   * Update the calendar with new data
   *
   * @param  object|string    dataSource    The calendar's datasource, same type as this.options.data
   * @param  boolean|function    afterLoad    Whether to execute afterLoad() on the data. Pass directly a function
   * if you don't want to use the afterLoad() callback
   */
  update: function (dataSource, afterLoad, updateMode) {
    'use strict';

    if (arguments.length === 0) {
      dataSource = this.options.data;
    }
    if (arguments.length < 2) {
      afterLoad = true;
    }
    if (arguments.length < 3) {
      updateMode = this.RESET_ALL_ON_UPDATE;
    }

    var domains = this.getDomainKeys();
    var self = this;
    this.getDatas(
      dataSource,
      new Date(domains[0]),
      this.getSubDomain(domains[domains.length - 1]).pop(),
      function () {
        self.fill();
        self.afterUpdate();
      },
      afterLoad,
      updateMode,
    );
  },

  /**
   * Set the legend
   *
   * @param array legend an array of integer, representing the different threshold value
   * @param array colorRange an array of 2 hex colors, for the minimum and maximum colors
   */
  setLegend: function () {
    'use strict';

    var oldLegend = this.options.legend.slice(0);
    if (arguments.length >= 1 && Array.isArray(arguments[0])) {
      this.options.legend = arguments[0];
    }
    if (arguments.length >= 2) {
      if (Array.isArray(arguments[1]) && arguments[1].length >= 2) {
        this.options.legendColors = [arguments[1][0], arguments[1][1]];
      } else {
        this.options.legendColors = arguments[1];
      }
    }

    if (
      (arguments.length > 0 && !arrayEquals(oldLegend, this.options.legend)) ||
      arguments.length >= 2
    ) {
      this.Legend.buildColors();
      this.fill();
    }

    this.Legend.redraw(
      this.graphDim.width -
        this.options.domainGutter -
        this.options.cellPadding,
    );
  },

  /**
   * Remove the legend
   *
   * @return bool False if there is no legend to remove
   */
  removeLegend: function () {
    'use strict';

    if (!this.options.displayLegend) {
      return false;
    }
    this.options.displayLegend = false;
    this.Legend.remove();
    return true;
  },

  /**
   * Display the legend
   *
   * @return bool False if the legend was already displayed
   */
  showLegend: function () {
    'use strict';

    if (this.options.displayLegend) {
      return false;
    }
    this.options.displayLegend = true;
    this.Legend.redraw(
      this.graphDim.width -
        this.options.domainGutter -
        this.options.cellPadding,
    );
    return true;
  },

  /**
   * Highlight dates
   *
   * Add a highlight class to a set of dates
   *
   * @since  3.3.5
   * @param  array Array of dates to highlight
   * @return bool True if dates were highlighted
   */
  highlight: function (args) {
    'use strict';

    if ((this.options.highlight = this.expandDateSetting(args)).length > 0) {
      this.fill();
      return true;
    }
    return false;
  },

  /**
   * Destroy the calendar
   *
   * Usage: cal = cal.destroy();
   *
   * @since  3.3.6
   * @param function A callback function to trigger after destroying the calendar
   * @return null
   */
  destroy: function (callback) {
    'use strict';

    this.root
      .transition()
      .duration(this.options.animationDuration)
      .attr('width', 0)
      .attr('height', 0)
      .remove()
      .each('end', function () {
        if (typeof callback === 'function') {
          callback();
        } else if (typeof callback !== 'undefined') {
          console.log('Provided callback for destroy() is not a function.');
        }
      });

    return null;
  },

  getSVG: function () {
    'use strict';

    var styles = {
      '.cal-heatmap-container': {},
      '.graph': {},
      '.graph-rect': {},
      'rect.highlight': {},
      'rect.now': {},
      'rect.highlight-now': {},
      'text.highlight': {},
      'text.now': {},
      'text.highlight-now': {},
      '.domain-background': {},
      '.graph-label': {},
      '.subdomain-text': {},
      '.q0': {},
      '.qi': {},
    };

    for (
      var j = 1, total = this.options.legend.length + 1;
      j <= total;
      j += 1
    ) {
      styles['.q' + j] = {};
    }

    var root = this.root;

    var whitelistStyles = [
      // SVG specific properties
      'stroke',
      'stroke-width',
      'stroke-opacity',
      'stroke-dasharray',
      'stroke-dashoffset',
      'stroke-linecap',
      'stroke-miterlimit',
      'fill',
      'fill-opacity',
      'fill-rule',
      'marker',
      'marker-start',
      'marker-mid',
      'marker-end',
      'alignement-baseline',
      'baseline-shift',
      'dominant-baseline',
      'glyph-orientation-horizontal',
      'glyph-orientation-vertical',
      'kerning',
      'text-anchor',
      'shape-rendering',

      // Text Specific properties
      'text-transform',
      'font-family',
      'font',
      'font-size',
      'font-weight',
    ];

    var filterStyles = function (attribute, property, value) {
      if (whitelistStyles.indexOf(property) !== -1) {
        styles[attribute][property] = value;
      }
    };

    var getElement = function (e) {
      return root.select(e)[0][0];
    };

    /* jshint forin:false */
    for (var element in styles) {
      if (!styles.hasOwnProperty(element)) {
        continue;
      }

      var dom = getElement(element);

      if (dom === null) {
        continue;
      }

      // The DOM Level 2 CSS way
      /* jshint maxdepth: false */
      if ('getComputedStyle' in window) {
        var cs = getComputedStyle(dom, null);
        if (cs.length !== 0) {
          for (var i = 0; i < cs.length; i += 1) {
            filterStyles(element, cs.item(i), cs.getPropertyValue(cs.item(i)));
          }

          // Opera workaround. Opera doesn"t support `item`/`length`
          // on CSSStyleDeclaration.
        } else {
          for (var k in cs) {
            if (cs.hasOwnProperty(k)) {
              filterStyles(element, k, cs[k]);
            }
          }
        }

        // The IE way
      } else if ('currentStyle' in dom) {
        var css = dom.currentStyle;
        for (var p in css) {
          filterStyles(element, p, css[p]);
        }
      }
    }

    var string =
      '<svg xmlns="http://www.w3.org/2000/svg" ' +
      'xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css"><![CDATA[ ';

    for (var style in styles) {
      string += style + ' {\n';
      for (var l in styles[style]) {
        string += '\t' + l + ':' + styles[style][l] + ';\n';
      }
      string += '}\n';
    }

    string += ']]></style>';
    string += new XMLSerializer().serializeToString(this.root[0][0]);
    string += '</svg>';

    return string;
  },
};

// =========================================================================//
// DOMAIN POSITION COMPUTATION                        //
// =========================================================================//

/**
 * Compute the position of a domain, relative to the calendar
 */
var DomainPosition = function () {
  'use strict';

  this.positions = d3.map();
};

DomainPosition.prototype.getPosition = function (d) {
  'use strict';

  return this.positions.get(d);
};

DomainPosition.prototype.getPositionFromIndex = function (i) {
  'use strict';

  var domains = this.getKeys();
  return this.positions.get(domains[i]);
};

DomainPosition.prototype.getLast = function () {
  'use strict';

  var domains = this.getKeys();
  return this.positions.get(domains[domains.length - 1]);
};

DomainPosition.prototype.setPosition = function (d, dim) {
  'use strict';

  this.positions.set(d, dim);
};

DomainPosition.prototype.shiftRightBy = function (exitingDomainDim) {
  'use strict';

  this.positions.forEach(function (key, value) {
    this.set(key, value - exitingDomainDim);
  });

  var domains = this.getKeys();
  this.positions.remove(domains[0]);
};

DomainPosition.prototype.shiftLeftBy = function (enteringDomainDim) {
  'use strict';

  this.positions.forEach(function (key, value) {
    this.set(key, value + enteringDomainDim);
  });

  var domains = this.getKeys();
  this.positions.remove(domains[domains.length - 1]);
};

DomainPosition.prototype.getKeys = function () {
  'use strict';

  return this.positions.keys().sort(function (a, b) {
    return parseInt(a, 10) - parseInt(b, 10);
  });
};

// =========================================================================//
// LEGEND                                  //
// =========================================================================//

var Legend = function (calendar) {
  'use strict';

  this.calendar = calendar;
  this.computeDim();

  if (calendar.options.legendColors !== null) {
    this.buildColors();
  }
};

Legend.prototype.computeDim = function () {
  'use strict';

  var options = this.calendar.options; // Shorter accessor for variable name mangling when minifying
  this.dim = {
    width:
      options.legendCellSize * (options.legend.length + 1) +
      options.legendCellPadding * options.legend.length,
    height: options.legendCellSize,
  };
};

Legend.prototype.remove = function () {
  'use strict';

  this.calendar.root.select('.graph-legend').remove();
  this.calendar.resize();
};

Legend.prototype.redraw = function (width) {
  'use strict';

  if (!this.calendar.options.displayLegend) {
    return false;
  }

  var parent = this;
  var calendar = this.calendar;
  var legend = calendar.root;
  var legendItem;
  var options = calendar.options; // Shorter accessor for variable name mangling when minifying

  this.computeDim();

  var _legend = options.legend.slice(0);
  _legend.push(_legend[_legend.length - 1] + 1);

  var legendElement = calendar.root.select('.graph-legend');
  if (legendElement[0][0] !== null) {
    legend = legendElement;
    legendItem = legend.select('g').selectAll('rect').data(_legend);
  } else {
    // Creating the new legend DOM if it doesn't already exist
    legend =
      options.legendVerticalPosition === 'top'
        ? legend.insert('svg', '.graph')
        : legend.append('svg');

    legend.attr('x', getLegendXPosition()).attr('y', getLegendYPosition());

    legendItem = legend
      .attr('class', 'graph-legend')
      .attr('height', parent.getDim('height'))
      .attr('width', parent.getDim('width'))
      .append('g')
      .selectAll()
      .data(_legend);
  }

  legendItem
    .enter()
    .append('rect')
    .call(legendCellLayout)
    .attr('class', function (d) {
      return calendar.Legend.getClass(d, calendar.legendScale === null);
    })
    .attr('fill-opacity', 0)
    .call(function (selection) {
      if (
        calendar.legendScale !== null &&
        options.legendColors !== null &&
        options.legendColors.hasOwnProperty('base')
      ) {
        selection.attr('fill', options.legendColors.base);
      }
    })
    .append('title');

  legendItem
    .exit()
    .transition()
    .duration(options.animationDuration)
    .attr('fill-opacity', 0)
    .remove();

  legendItem
    .transition()
    .delay(function (d, i) {
      return (options.animationDuration * i) / 10;
    })
    .call(legendCellLayout)
    .attr('fill-opacity', 1)
    .call(function (element) {
      element.attr('fill', function (d, i) {
        if (calendar.legendScale === null) {
          return '';
        }

        if (i === 0) {
          return calendar.legendScale(d - 1);
        }
        return calendar.legendScale(options.legend[i - 1]);
      });

      element.attr('class', function (d) {
        return calendar.Legend.getClass(d, calendar.legendScale === null);
      });
    });

  function legendCellLayout(selection) {
    selection
      .attr('width', options.legendCellSize)
      .attr('height', options.legendCellSize)
      .attr('rx', options.legendCellRadius)
      .attr('ry', options.legendCellRadius)
      .attr('x', function (d, i) {
        return i * (options.legendCellSize + options.legendCellPadding);
      });
  }

  legendItem.select('title').text(function (d, i) {
    if (i === 0) {
      return calendar.formatStringWithObject(options.legendTitleFormat.lower, {
        min: options.legend[i],
        name: options.itemName[1],
      });
    } else if (i === _legend.length - 1) {
      return calendar.formatStringWithObject(options.legendTitleFormat.upper, {
        max: options.legend[i - 1],
        name: options.itemName[1],
      });
    } else {
      return calendar.formatStringWithObject(options.legendTitleFormat.inner, {
        down: options.legend[i - 1],
        up: options.legend[i],
        name: options.itemName[1],
      });
    }
  });
  legendItem
    .on('mouseover', function (d) {
      calendar.legendTip.show(d, this);
    })
    .on('mouseout', function () {
      calendar.legendTip.hide();
    });

  legend
    .transition()
    .duration(options.animationDuration)
    .attr('x', getLegendXPosition())
    .attr('y', getLegendYPosition())
    .attr('width', parent.getDim('width'))
    .attr('height', parent.getDim('height'));

  legend
    .select('g')
    .transition()
    .duration(options.animationDuration)
    .attr('transform', function () {
      if (options.legendOrientation === 'vertical') {
        return (
          'rotate(90 ' +
          options.legendCellSize / 2 +
          ' ' +
          options.legendCellSize / 2 +
          ')'
        );
      }
      return '';
    });

  function getLegendXPosition() {
    switch (options.legendHorizontalPosition) {
      case 'right':
        if (
          options.legendVerticalPosition === 'center' ||
          options.legendVerticalPosition === 'middle'
        ) {
          return width + options.legendMargin[3];
        }
        return width - parent.getDim('width') - options.legendMargin[1];
      case 'middle':
      case 'center':
        return Math.round(width / 2 - parent.getDim('width') / 2);
      default:
        return options.legendMargin[3];
    }
  }

  function getLegendYPosition() {
    if (options.legendVerticalPosition === 'bottom') {
      return (
        calendar.graphDim.height +
        options.legendMargin[0] -
        options.domainGutter -
        options.cellPadding
      );
    }
    return options.legendMargin[0];
  }

  calendar.resize();
};

/**
 * Return the dimension of the legend
 *
 * Takes into account rotation
 *
 * @param  string axis Width or height
 * @return int height or width in pixels
 */
Legend.prototype.getDim = function (axis) {
  'use strict';

  var isHorizontal = this.calendar.options.legendOrientation === 'horizontal';

  switch (axis) {
    case 'width':
      return this.dim[isHorizontal ? 'width' : 'height'];
    case 'height':
      return this.dim[isHorizontal ? 'height' : 'width'];
  }
};

Legend.prototype.buildColors = function () {
  'use strict';

  var options = this.calendar.options; // Shorter accessor for variable name mangling when minifying

  if (options.legendColors === null) {
    this.calendar.legendScale = null;
    return false;
  }

  var _colorRange = [];

  if (Array.isArray(options.legendColors)) {
    _colorRange = options.legendColors;
  } else if (
    options.legendColors.hasOwnProperty('min') &&
    options.legendColors.hasOwnProperty('max')
  ) {
    _colorRange = [options.legendColors.min, options.legendColors.max];
  } else {
    options.legendColors = null;
    return false;
  }

  var _legend = options.legend.slice(0);

  if (_legend[0] > 0) {
    _legend.unshift(0);
  } else if (_legend[0] <= 0) {
    // Let's guess the leftmost value, it we have to add one
    _legend.unshift(
      _legend[0] - (_legend[_legend.length - 1] - _legend[0]) / _legend.length,
    );
  }
  var colorScale;
  if (options.legendColors.hasOwnProperty('colorScale')) {
    colorScale = options.legendColors.colorScale;
  } else {
    colorScale = d3.scale
      .linear()
      .range(_colorRange)
      .interpolate(d3.interpolateHcl)
      .domain([d3.min(_legend), d3.max(_legend)]);
  }

  var legendColors = _legend.map(function (element) {
    return colorScale(element);
  });
  this.calendar.legendScale = d3.scale
    .threshold()
    .domain(options.legend)
    .range(legendColors);

  return true;
};

/**
 * Return the classname on the legend for the specified value
 *
 * @param integer n Value associated to a date
 * @param bool withCssClass Whether to display the css class used to style the cell.
 *                          Disabling will allow styling directly via html fill attribute
 *
 * @return string Classname according to the legend
 */
Legend.prototype.getClass = function (n, withCssClass) {
  'use strict';

  if (n === null || isNaN(n)) {
    return '';
  }

  var index = [this.calendar.options.legend.length + 1];

  for (
    var i = 0, total = this.calendar.options.legend.length - 1;
    i <= total;
    i += 1
  ) {
    if (this.calendar.options.legend[0] > 0 && n < 0) {
      index = ['1', 'i'];
      break;
    }

    if (n <= this.calendar.options.legend[i]) {
      index = [i + 1];
      break;
    }
  }

  if (n === 0) {
    index.push(0);
  }

  index.unshift('');
  return (index.join(' r') + (withCssClass ? index.join(' q') : '')).trim();
};

/**
 * #source http://stackoverflow.com/a/383245/805649
 */
function mergeRecursive(obj1, obj2) {
  'use strict';

  /*jshint forin:false */
  for (var p in obj2) {
    try {
      // Property in destination object set; update its value.
      if (obj2[p].constructor === Object) {
        obj1[p] = mergeRecursive(obj1[p], obj2[p]);
      } else {
        obj1[p] = obj2[p];
      }
    } catch (e) {
      // Property in destination object not set; create it and set its value.
      obj1[p] = obj2[p];
    }
  }

  return obj1;
}

/**
 * Check if 2 arrays are equals
 *
 * @link http://stackoverflow.com/a/14853974/805649
 * @param  array array the array to compare to
 * @return bool true of the 2 arrays are equals
 */
function arrayEquals(arrayA, arrayB) {
  'use strict';

  // if the other array is a falsy value, return
  if (!arrayB || !arrayA) {
    return false;
  }

  // compare lengths - can save a lot of time
  if (arrayA.length !== arrayB.length) {
    return false;
  }

  for (var i = 0; i < arrayA.length; i += 1) {
    // Check if we have nested arrays
    if (arrayA[i] instanceof Array && arrayB[i] instanceof Array) {
      // recurse into the nested arrays
      if (!arrayEquals(arrayA[i], arrayB[i])) {
        return false;
      }
    } else if (arrayA[i] !== arrayB[i]) {
      // Warning - two different object instances will never be equal: {x:20} != {x:20}
      return false;
    }
  }
  return true;
}

export default CalHeatMap;

相关信息

superset 源码目录

相关文章

superset caches 源码

superset changelog 源码

superset generate_email 源码

superset superset_config 源码

superset babel.config 源码

superset docusaurus.config 源码

superset sidebars 源码

superset data 源码

superset utils 源码

superset matomo 源码

0  赞