diff --git a/web_widget_heatmap/__init__.py b/web_widget_heatmap/__init__.py new file mode 100644 index 00000000..ef5ae358 --- /dev/null +++ b/web_widget_heatmap/__init__.py @@ -0,0 +1 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). diff --git a/web_widget_heatmap/__manifest__.py b/web_widget_heatmap/__manifest__.py new file mode 100644 index 00000000..320c808b --- /dev/null +++ b/web_widget_heatmap/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2019 Dinar Gabbasov +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Web Widget HeatMap", + "summary": "Displaying your records in calendar HeatMap", + "version": "11.0.1.0.0", + "development_status": "Stable", + "category": "Extra Tools", + "website": "https://github.com/OCA/web/tree/11.0/web_widget_heatmap", + "author": "Dinar Gabbasov, Odoo Community Association (OCA)", + "maintainers": ["GabbasovDinar"], + "license": "AGPL-3", + "application": False, + "installable": True, + "preloadable": True, + "external_dependencies": { + "python": [], + "bin": [], + }, + "depends": [ + "web", + ], + "data": [ + "views/web_widget_heatmap_template.xml", + ], + "demo": [], + "qweb": [ + "static/src/xml/base.xml" + ] +} diff --git a/web_widget_heatmap/readme/CONTRIBUTORS.rst b/web_widget_heatmap/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..8b462832 --- /dev/null +++ b/web_widget_heatmap/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Dinar Gabbasov diff --git a/web_widget_heatmap/readme/CREDITS.rst b/web_widget_heatmap/readme/CREDITS.rst new file mode 100644 index 00000000..14fa2e5f --- /dev/null +++ b/web_widget_heatmap/readme/CREDITS.rst @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +* Georg A. G. Notter diff --git a/web_widget_heatmap/readme/DESCRIPTION.rst b/web_widget_heatmap/readme/DESCRIPTION.rst new file mode 100644 index 00000000..b6d0e2f3 --- /dev/null +++ b/web_widget_heatmap/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +This module aims to add a heatmap calendar to Odoo. + +It's a `cal-heatmap `_ lib integration. diff --git a/web_widget_heatmap/readme/USAGE.rst b/web_widget_heatmap/readme/USAGE.rst new file mode 100644 index 00000000..35ab4b84 --- /dev/null +++ b/web_widget_heatmap/readme/USAGE.rst @@ -0,0 +1,35 @@ +To use this module, you need to declare a One2many or Many2many field:: + + sale_order_ids = fields.One2many( + "sale.order", + "partner_id", + string="Sales Order", + ) + +In the view declaration, put widget="heatmap" attribute in the field tag:: + + ... + +
+ ... + + ... + +
+ ... + +Widget Options:: + + cellSize - Size of each subDomain cell, in pixel. + ... + +
+ ... + + ... + +
+ ... + + +* All widget options you can find in the official `documentation `_ of the cal-heatmap lib. diff --git a/web_widget_heatmap/static/description/icon.png b/web_widget_heatmap/static/description/icon.png new file mode 100644 index 00000000..b43a0a13 Binary files /dev/null and b/web_widget_heatmap/static/description/icon.png differ diff --git a/web_widget_heatmap/static/description/index.html b/web_widget_heatmap/static/description/index.html new file mode 100644 index 00000000..c8ba05fb --- /dev/null +++ b/web_widget_heatmap/static/description/index.html @@ -0,0 +1,75 @@ +
+
+
+

Web Widget HeatMap

+

This module aims to add a heatmap calendar to Odoo.

+

It's a cal-heatmap lib integration.

+
+
+
+ +
+
+
+

Installation

+
+
+

Install this module in a usual way

+
+
+
+ +
+
+
+

Usage

+
+
+

You need to declare a One2many or Many2many field: +

+                    sale_order_ids = fields.One2many("sale.order", "partner_id", "Sales Order")
+                
+

In the view declaration, put widget="heatmap" attribute in the field tag:

+
+                ...
+                <field name="arch" type="xml">
+                    <form string="View name">
+                        ...
+                        <field name="sale_order_ids" widget="heatmap"/>
+                        ...
+                    </form>
+                </field>
+                ...
+            
+

+

For further information, please visit: +

+

+
+
+
+ +
+
+
+

Credits

+
+
+

Contributors

+ +
+
+

Maintainer

+

+ This module is maintained by the OCA.
+ OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.
+ To contribute to this module, please visit http://odoo-community.org.
+ +

+
+
+
diff --git a/web_widget_heatmap/static/lib/css/cal-heatmap.css b/web_widget_heatmap/static/lib/css/cal-heatmap.css new file mode 100644 index 00000000..16125699 --- /dev/null +++ b/web_widget_heatmap/static/lib/css/cal-heatmap.css @@ -0,0 +1,145 @@ +/* Cal-HeatMap CSS */ + +.cal-heatmap-container { + display: block; +} + +.cal-heatmap-container .graph +{ + font-family: "Lucida Grande", Lucida, Verdana, sans-serif; +} + +.cal-heatmap-container .graph-label +{ + fill: #999; + font-size: 10px +} + +.cal-heatmap-container .graph, .cal-heatmap-container .graph-legend rect { + shape-rendering: crispedges +} + +.cal-heatmap-container .graph-rect +{ + fill: #ededed +} + +.cal-heatmap-container .graph-subdomain-group rect:hover +{ + stroke: #000; + stroke-width: 1px +} + +.cal-heatmap-container .subdomain-text { + font-size: 8px; + fill: #999; + pointer-events: none +} + +.cal-heatmap-container .hover_cursor:hover { + cursor: pointer +} + +.cal-heatmap-container .qi { + background-color: #999; + fill: #999 +} + +/* +Remove comment to apply this style to date with value equal to 0 +.q0 +{ + background-color: #fff; + fill: #fff; + stroke: #ededed +} +*/ + +.cal-heatmap-container .q1 +{ + background-color: #dae289; + fill: #dae289 +} + +.cal-heatmap-container .q2 +{ + background-color: #cedb9c; + fill: #9cc069 +} + +.cal-heatmap-container .q3 +{ + background-color: #b5cf6b; + fill: #669d45 +} + +.cal-heatmap-container .q4 +{ + background-color: #637939; + fill: #637939 +} + +.cal-heatmap-container .q5 +{ + background-color: #3b6427; + fill: #3b6427 +} + +.cal-heatmap-container rect.highlight +{ + stroke:#444; + stroke-width:1 +} + +.cal-heatmap-container text.highlight +{ + fill: #444 +} + +.cal-heatmap-container rect.highlight-now +{ + stroke: red +} + +.cal-heatmap-container text.highlight-now +{ + fill: red; + font-weight: 800 +} + +.cal-heatmap-container .domain-background { + fill: none; + shape-rendering: crispedges +} + +.ch-tooltip { + padding: 10px; + background: #222; + color: #bbb; + font-size: 12px; + line-height: 1.4; + width: 140px; + position: absolute; + z-index: 99999; + text-align: center; + border-radius: 2px; + box-shadow: 2px 2px 2px rgba(0,0,0,0.2); + display: none; + box-sizing: border-box; +} + +.ch-tooltip::after{ + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + content: ""; + padding: 0; + display: block; + bottom: -6px; + left: 50%; + margin-left: -6px; + border-width: 6px 6px 0; + border-top-color: #222; +} diff --git a/web_widget_heatmap/static/lib/js/cal-heatmap.js b/web_widget_heatmap/static/lib/js/cal-heatmap.js new file mode 100644 index 00000000..04c6a098 --- /dev/null +++ b/web_widget_heatmap/static/lib/js/cal-heatmap.js @@ -0,0 +1,3487 @@ +/*! cal-heatmap v3.6.2 (Mon Oct 10 2016 01:36:20) + * --------------------------------------------- + * Cal-Heatmap is a javascript module to create calendar heatmap to visualize time series data + * https://github.com/wa0x6e/cal-heatmap + * Licensed under the MIT license + * Copyright 2014 Wan Qi Chen + */ + + + var d3 = typeof require === "function" ? require("d3") : window.d3; + + + +var CalHeatMap = function() { + "use strict"; + + var self = this; + + this.allowedDataType = ["json", "csv", "tsv", "txt"]; + + // Default settings + this.options = { + // selector string of the container to append the graph to + // Accept any string value accepted by document.querySelector or CSS3 + // or an Element object + itemSelector: "#cal-heatmap", + + // Whether to paint the calendar on init() + // Used by testsuite to reduce testing time + paintOnLoad: true, + + // ================================================ + // DOMAIN + // ================================================ + + // Number of domain to display on the graph + range: 12, + + // Size of each cell, in pixel + cellSize: 10, + + // Padding between each cell, in pixel + cellPadding: 2, + + // For rounded subdomain rectangles, in pixels + cellRadius: 0, + + domainGutter: 2, + + domainMargin: [0, 0, 0, 0], + + domain: "hour", + + subDomain: "min", + + // Number of columns to split the subDomains to + // If not null, will takes precedence over rowLimit + colLimit: null, + + // Number of rows to split the subDomains to + // Will be ignored if colLimit is not null + rowLimit: null, + + // First day of the week is Monday + // 0 to start the week on Sunday + weekStartOnMonday: true, + + // Start date of the graph + // @default now + start: new Date(), + + minDate: null, + + maxDate: null, + + // ================================================ + // DATA + // ================================================ + + // Data source + // URL, where to fetch the original datas + data: "", + + // Data type + // Default: json + dataType: this.allowedDataType[0], + + // Payload sent when using POST http method + // Leave to null (default) for GET request + // Expect a string, formatted like "a=b;c=d" + dataPostPayload: null, + + // Whether to consider missing date:value from the datasource + // as equal to 0, or just leave them as missing + considerMissingDataAsZero: false, + + // Load remote data on calendar creation + // When false, the calendar will be left empty + loadOnInit: true, + + // Calendar orientation + // false: display domains side by side + // true : display domains one under the other + verticalOrientation: false, + + // Domain dynamic width/height + // The width on a domain depends on the number of + domainDynamicDimension: true, + + // Domain Label properties + label: { + // valid: top, right, bottom, left + position: "bottom", + + // Valid: left, center, right + // Also valid are the direct svg values: start, middle, end + align: "center", + + // By default, there is no margin/padding around the label + offset: { + x: 0, + y: 0 + }, + + rotate: null, + + // Used only on vertical orientation + width: 100, + + // Used only on horizontal orientation + height: null + }, + + // ================================================ + // LEGEND + // ================================================ + + // Threshold for the legend + legend: [10, 20, 30, 40], + + // Whether to display the legend + displayLegend: true, + + legendCellSize: 10, + + legendCellPadding: 2, + + legendMargin: [0, 0, 0, 0], + + // Legend vertical position + // top: place legend above calendar + // bottom: place legend below the calendar + legendVerticalPosition: "bottom", + + // Legend horizontal position + // accepted values: left, center, right + legendHorizontalPosition: "left", + + // Legend rotation + // accepted values: horizontal, vertical + legendOrientation: "horizontal", + + // Objects holding all the heatmap different colors + // null to disable, and use the default css styles + // + // Examples: + // legendColors: { + // min: "green", + // max: "red", + // empty: "#ffffff", + // base: "grey", + // overflow: "red" + // } + legendColors: null, + + // ================================================ + // HIGHLIGHT + // ================================================ + + // List of dates to highlight + // Valid values: + // - []: don't highlight anything + // - "now": highlight the current date + // - an array of Date objects: highlight the specified dates + highlight: [], + + // ================================================ + // TEXT FORMATTING / i18n + // ================================================ + + // Name of the items to represent in the calendar + itemName: ["item", "items"], + + // Formatting of the domain label + // @default: null, will use the formatting according to domain type + // Accept a string used as specifier by d3.time.format() + // or a function + // + // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting + // for accepted date formatting used by d3.time.format() + domainLabelFormat: null, + + // Formatting of the title displayed when hovering a subDomain cell + subDomainTitleFormat: { + empty: "{date}", + filled: "{count} {name} {connector} {date}" + }, + + // Formatting of the {date} used in subDomainTitleFormat + // @default: null, will use the formatting according to subDomain type + // Accept a string used as specifier by d3.time.format() + // or a function + // + // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting + // for accepted date formatting used by d3.time.format() + subDomainDateFormat: null, + + // Formatting of the text inside each subDomain cell + // @default: null, no text + // Accept a string used as specifier by d3.time.format() + // or a function + // + // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting + // for accepted date formatting used by d3.time.format() + subDomainTextFormat: null, + + // Formatting of the title displayed when hovering a legend cell + legendTitleFormat: { + lower: "less than {min} {name}", + inner: "between {down} and {up} {name}", + upper: "more than {max} {name}" + }, + + // Animation duration, in ms + animationDuration: 500, + + nextSelector: false, + + previousSelector: false, + + itemNamespace: "cal-heatmap", + + tooltip: false, + + // ================================================ + // EVENTS CALLBACK + // ================================================ + + // Callback when clicking on a time block + onClick: null, + + // Callback after painting the empty calendar + // Can be used to trigger an API call, once the calendar is ready to be filled + afterLoad: null, + + // Callback after loading the next domain in the calendar + afterLoadNextDomain: null, + + // Callback after loading the previous domain in the calendar + afterLoadPreviousDomain: null, + + // Callback after finishing all actions on the calendar + onComplete: null, + + // Callback after fetching the datas, but before applying them to the calendar + // Used mainly to convert the datas if they're not formatted like expected + // Takes the fetched "data" object as argument, must return a json object + // formatted like {timestamp:count, timestamp2:count2}, + afterLoadData: function(data) { return data; }, + + // Callback triggered after calling next(). + // The `status` argument is equal to true if there is no + // more next domain to load + // + // This callback is also executed once, after calling previous(), + // only when the max domain is reached + onMaxDomainReached: null, + + // Callback triggered after calling previous(). + // The `status` argument is equal to true if there is no + // more previous domain to load + // + // This callback is also executed once, after calling next(), + // only when the min domain is reached + onMinDomainReached: null + }; + + this._domainType = { + "min": { + name: "minute", + level: 10, + maxItemNumber: 60, + defaultRowNumber: 10, + defaultColumnNumber: 6, + row: function(d) { return self.getSubDomainRowNumber(d); }, + column: function(d) { return self.getSubDomainColumnNumber(d); }, + position: { + x: function(d) { return Math.floor(d.getMinutes() / self._domainType.min.row(d)); }, + y: function(d) { return d.getMinutes() % self._domainType.min.row(d); } + }, + format: { + date: "%H:%M, %A %B %-e, %Y", + legend: "", + connector: "at" + }, + extractUnit: function(d) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes()).getTime(); + } + }, + "hour": { + name: "hour", + level: 20, + maxItemNumber: function(d) { + switch(self.options.domain) { + case "day": + return 24; + case "week": + return 24 * 7; + case "month": + return 24 * (self.options.domainDynamicDimension ? self.getDayCountInMonth(d): 31); + } + }, + defaultRowNumber: 6, + defaultColumnNumber: function(d) { + switch(self.options.domain) { + case "day": + return 4; + case "week": + return 28; + case "month": + return self.options.domainDynamicDimension ? self.getDayCountInMonth(d): 31; + } + }, + row: function(d) { return self.getSubDomainRowNumber(d); }, + column: function(d) { return self.getSubDomainColumnNumber(d); }, + position: { + x: function(d) { + if (self.options.domain === "month") { + if (self.options.colLimit > 0 || self.options.rowLimit > 0) { + return Math.floor((d.getHours() + (d.getDate()-1)*24) / self._domainType.hour.row(d)); + } + return Math.floor(d.getHours() / self._domainType.hour.row(d)) + (d.getDate()-1)*4; + } else if (self.options.domain === "week") { + if (self.options.colLimit > 0 || self.options.rowLimit > 0) { + return Math.floor((d.getHours() + self.getWeekDay(d)*24) / self._domainType.hour.row(d)); + } + return Math.floor(d.getHours() / self._domainType.hour.row(d)) + self.getWeekDay(d)*4; + } + return Math.floor(d.getHours() / self._domainType.hour.row(d)); + }, + y: function(d) { + var p = d.getHours(); + if (self.options.colLimit > 0 || self.options.rowLimit > 0) { + switch(self.options.domain) { + case "month": + p += (d.getDate()-1) * 24; + break; + case "week": + p += self.getWeekDay(d) * 24; + break; + } + } + return Math.floor(p % self._domainType.hour.row(d)); + } + }, + format: { + date: "%Hh, %A %B %-e, %Y", + legend: "%H:00", + connector: "at" + }, + extractUnit: function(d) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime(); + } + }, + "day": { + name: "day", + level: 30, + maxItemNumber: function(d) { + switch(self.options.domain) { + case "week": + return 7; + case "month": + return self.options.domainDynamicDimension ? self.getDayCountInMonth(d) : 31; + case "year": + return self.options.domainDynamicDimension ? self.getDayCountInYear(d) : 366; + } + }, + defaultColumnNumber: function(d) { + d = new Date(d); + switch(self.options.domain) { + case "week": + return 1; + case "month": + return (self.options.domainDynamicDimension && !self.options.verticalOrientation) ? (self.getWeekNumber(new Date(d.getFullYear(), d.getMonth()+1, 0)) - self.getWeekNumber(d) + 1): 6; + case "year": + return (self.options.domainDynamicDimension ? (self.getWeekNumber(new Date(d.getFullYear(), 11, 31)) - self.getWeekNumber(new Date(d.getFullYear(), 0)) + 1): 54); + } + }, + defaultRowNumber: 7, + row: function(d) { return self.getSubDomainRowNumber(d); }, + column: function(d) { return self.getSubDomainColumnNumber(d); }, + position: { + x: function(d) { + switch(self.options.domain) { + case "week": + return Math.floor(self.getWeekDay(d) / self._domainType.day.row(d)); + case "month": + if (self.options.colLimit > 0 || self.options.rowLimit > 0) { + return Math.floor((d.getDate() - 1)/ self._domainType.day.row(d)); + } + return self.getWeekNumber(d) - self.getWeekNumber(new Date(d.getFullYear(), d.getMonth())); + case "year": + if (self.options.colLimit > 0 || self.options.rowLimit > 0) { + return Math.floor((self.getDayOfYear(d) - 1) / self._domainType.day.row(d)); + } + return self.getWeekNumber(d); + } + }, + y: function(d) { + var p = self.getWeekDay(d); + if (self.options.colLimit > 0 || self.options.rowLimit > 0) { + switch(self.options.domain) { + case "year": + p = self.getDayOfYear(d) - 1; + break; + case "week": + p = self.getWeekDay(d); + break; + case "month": + p = d.getDate() - 1; + break; + } + } + return Math.floor(p % self._domainType.day.row(d)); + } + }, + format: { + date: "%A %B %-e, %Y", + legend: "%e %b", + connector: "on" + }, + extractUnit: function(d) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + } + }, + "week": { + name: "week", + level: 40, + maxItemNumber: 54, + defaultColumnNumber: function(d) { + d = new Date(d); + switch(self.options.domain) { + case "year": + return self._domainType.week.maxItemNumber; + case "month": + return self.options.domainDynamicDimension ? self.getWeekNumber(new Date(d.getFullYear(), d.getMonth()+1, 0)) - self.getWeekNumber(d) : 5; + } + }, + defaultRowNumber: 1, + row: function(d) { return self.getSubDomainRowNumber(d); }, + column: function(d) { return self.getSubDomainColumnNumber(d); }, + position: { + x: function(d) { + switch(self.options.domain) { + case "year": + return Math.floor(self.getWeekNumber(d) / self._domainType.week.row(d)); + case "month": + return Math.floor(self.getMonthWeekNumber(d) / self._domainType.week.row(d)); + } + }, + y: function(d) { + return self.getWeekNumber(d) % self._domainType.week.row(d); + } + }, + format: { + date: "%B Week #%W", + legend: "%B Week #%W", + connector: "in" + }, + extractUnit: function(d) { + var dt = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + // According to ISO-8601, week number computation are based on week starting on Monday + var weekDay = dt.getDay() - (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.tooltip = d3.select(self.options.itemSelector) + .attr("style", function() { + var current = d3.select(self.options.itemSelector).attr("style"); + return (current !== null ? current : "") + "position:relative;"; + }) + .append("div") + .attr("class", "ch-tooltip") + ; + + self.root.attr("x", 0).attr("y", 0).append("svg").attr("class", "graph"); + + self.Legend = new Legend(self); + + if (self.options.paintOnLoad) { + _initCalendar(); + } + + return true; + }; + + function _initCalendar() { + self.verticalDomainLabel = (self.options.label.position === "top" || self.options.label.position === "bottom"); + + self.domainVerticalLabelHeight = self.options.label.height === null ? Math.max(25, self.options.cellSize*2): self.options.label.height; + self.domainHorizontalLabelWidth = 0; + + if (self.options.domainLabelFormat === "" && self.options.label.height === null) { + self.domainVerticalLabelHeight = 0; + } + + if (!self.verticalDomainLabel) { + self.domainVerticalLabelHeight = 0; + self.domainHorizontalLabelWidth = self.options.label.width; + } + + self.paint(); + + // =========================================================================// + // ATTACHING DOMAIN NAVIGATION EVENT // + // =========================================================================// + if (self.options.nextSelector !== false) { + d3.select(self.options.nextSelector).on("click." + self.options.itemNamespace, function() { + d3.event.preventDefault(); + return self.loadNextDomain(1); + }); + } + + if (self.options.previousSelector !== false) { + d3.select(self.options.previousSelector).on("click." + self.options.itemNamespace, function() { + d3.event.preventDefault(); + return self.loadPreviousDomain(1); + }); + } + + self.Legend.redraw(self.graphDim.width - self.options.domainGutter - self.options.cellPadding); + self.afterLoad(); + + var domains = self.getDomainKeys(); + + // Fill the graph with some datas + if (self.options.loadOnInit) { + self.getDatas( + self.options.data, + new Date(domains[0]), + self.getSubDomain(domains[domains.length-1]).pop(), + function() { + self.fill(); + self.onComplete(); + } + ); + } else { + self.onComplete(); + } + + self.checkIfMinDomainIsReached(domains[0]); + self.checkIfMaxDomainIsReached(self.getNextDomain().getTime()); + } + + // Return the width of the domain block, without the domain gutter + // @param int d Domain start timestamp + function w(d, outer) { + var width = self.options.cellSize*self._domainType[self.options.subDomain].column(d) + self.options.cellPadding*self._domainType[self.options.subDomain].column(d); + if (arguments.length === 2 && outer === true) { + return width += self.domainHorizontalLabelWidth + self.options.domainGutter + self.options.domainMargin[1] + self.options.domainMargin[3]; + } + return width; + } + + // Return the height of the domain block, without the domain gutter + function h(d, outer) { + var height = self.options.cellSize*self._domainType[self.options.subDomain].row(d) + self.options.cellPadding*self._domainType[self.options.subDomain].row(d); + if (arguments.length === 2 && outer === true) { + height += self.options.domainGutter + self.domainVerticalLabelHeight + self.options.domainMargin[0] + self.options.domainMargin[2]; + } + return height; + } + + /** + * + * + * @param int navigationDir + */ + this.paint = function(navigationDir) { + + var options = self.options; + + if (arguments.length === 0) { + navigationDir = false; + } + + // Painting all the domains + var domainSvg = self.root.select(".graph") + .selectAll(".graph-domain") + .data( + function() { + var data = self.getDomainKeys(); + return navigationDir === self.NAVIGATE_LEFT ? data.reverse(): data; + }, + function(d) { return d; } + ) + ; + + var enteringDomainDim = 0; + var exitingDomainDim = 0; + + // =========================================================================// + // PAINTING DOMAIN // + // =========================================================================// + + var svg = domainSvg + .enter() + .append("svg") + .attr("width", function(d) { + return w(d, true); + }) + .attr("height", function(d) { + return h(d, true); + }) + .attr("x", function(d) { + if (options.verticalOrientation) { + self.graphDim.width = Math.max(self.graphDim.width, w(d, true)); + return 0; + } else { + return getDomainPosition(d, self.graphDim, "width", w(d, true)); + } + }) + .attr("y", function(d) { + if (options.verticalOrientation) { + return getDomainPosition(d, self.graphDim, "height", h(d, true)); + } else { + self.graphDim.height = Math.max(self.graphDim.height, h(d, true)); + return 0; + } + }) + .attr("class", function(d) { + var classname = "graph-domain"; + var date = new Date(d); + switch(options.domain) { + case "hour": + classname += " h_" + date.getHours(); + /* falls through */ + case "day": + classname += " d_" + date.getDate() + " dy_" + date.getDay(); + /* falls through */ + case "week": + classname += " w_" + self.getWeekNumber(date); + /* falls through */ + case "month": + classname += " m_" + (date.getMonth() + 1); + /* falls through */ + case "year": + classname += " y_" + date.getFullYear(); + } + return classname; + }) + ; + + self.lastInsertedSvg = svg; + + function getDomainPosition(domainIndex, graphDim, axis, domainDim) { + var tmp = 0; + switch(navigationDir) { + case false: + tmp = graphDim[axis]; + + graphDim[axis] += domainDim; + self.domainPosition.setPosition(domainIndex, tmp); + return tmp; + + case self.NAVIGATE_RIGHT: + self.domainPosition.setPosition(domainIndex, graphDim[axis]); + + enteringDomainDim = domainDim; + exitingDomainDim = self.domainPosition.getPositionFromIndex(1); + + self.domainPosition.shiftRightBy(exitingDomainDim); + return graphDim[axis]; + + case self.NAVIGATE_LEFT: + tmp = -domainDim; + + enteringDomainDim = -tmp; + exitingDomainDim = graphDim[axis] - self.domainPosition.getLast(); + + self.domainPosition.setPosition(domainIndex, tmp); + self.domainPosition.shiftLeftBy(enteringDomainDim); + return tmp; + } + } + + svg.append("rect") + .attr("width", function(d) { return w(d, true) - options.domainGutter - options.cellPadding; }) + .attr("height", function(d) { return h(d, true) - options.domainGutter - options.cellPadding; }) + .attr("class", "domain-background") + ; + + // =========================================================================// + // PAINTING SUBDOMAINS // + // =========================================================================// + var subDomainSvgGroup = svg.append("svg") + .attr("x", function() { + if (options.label.position === "left") { + return self.domainHorizontalLabelWidth + options.domainMargin[3]; + } else { + return options.domainMargin[3]; + } + }) + .attr("y", function() { + if (options.label.position === "top") { + return self.domainVerticalLabelHeight + options.domainMargin[0]; + } else { + return options.domainMargin[0]; + } + }) + .attr("class", "graph-subdomain-group") + ; + + var rect = subDomainSvgGroup + .selectAll("g") + .data(function(d) { return self._domains.get(d); }) + .enter() + .append("g") + ; + + rect + .append("rect") + .attr("class", function(d) { + return "graph-rect" + self.getHighlightClassName(d.t) + (options.onClick !== null ? " hover_cursor": ""); + }) + .attr("width", options.cellSize) + .attr("height", options.cellSize) + .attr("x", function(d) { return self.positionSubDomainX(d.t); }) + .attr("y", function(d) { return self.positionSubDomainY(d.t); }) + .on("click", function(d) { + if (options.onClick !== null) { + return self.onClick(new Date(d.t), d.v); + } + }) + .call(function(selection) { + if (options.cellRadius > 0) { + selection + .attr("rx", options.cellRadius) + .attr("ry", options.cellRadius) + ; + } + + if (self.legendScale !== null && options.legendColors !== null && options.legendColors.hasOwnProperty("base")) { + selection.attr("fill", options.legendColors.base); + } + + if (options.tooltip) { + selection.on("mouseover", function(d) { + var domainNode = this.parentNode.parentNode; + + self.tooltip + .html(self.getSubDomainTitle(d)) + .attr("style", "display: block;") + ; + + var tooltipPositionX = self.positionSubDomainX(d.t) - self.tooltip[0][0].offsetWidth/2 + options.cellSize/2; + var tooltipPositionY = self.positionSubDomainY(d.t) - self.tooltip[0][0].offsetHeight - options.cellSize/2; + + // Offset by the domain position + tooltipPositionX += parseInt(domainNode.getAttribute("x"), 10); + tooltipPositionY += parseInt(domainNode.getAttribute("y"), 10); + + // Offset by the calendar position (when legend is left/top) + tooltipPositionX += parseInt(self.root.select(".graph").attr("x"), 10); + tooltipPositionY += parseInt(self.root.select(".graph").attr("y"), 10); + + // Offset by the inside domain position (when label is left/top) + tooltipPositionX += parseInt(domainNode.parentNode.getAttribute("x"), 10); + tooltipPositionY += parseInt(domainNode.parentNode.getAttribute("y"), 10); + + self.tooltip.attr("style", + "display: block; " + + "left: " + tooltipPositionX + "px; " + + "top: " + tooltipPositionY + "px;") + ; + }); + + selection.on("mouseout", function() { + self.tooltip + .attr("style", "display:none") + .html(""); + }); + } + }) + ; + + // Appending a title to each subdomain + if (!options.tooltip) { + rect.append("title").text(function(d){ return self.formatDate(new Date(d.t), options.subDomainDateFormat); }); + } + + // =========================================================================// + // PAINTING LABEL // + // =========================================================================// + if (options.domainLabelFormat !== "") { + svg.append("text") + .attr("class", "graph-label") + .attr("y", function(d) { + var y = options.domainMargin[0]; + switch(options.label.position) { + case "top": + y += self.domainVerticalLabelHeight/2; + break; + case "bottom": + y += h(d) + self.domainVerticalLabelHeight/2; + } + + return y + options.label.offset.y * + ( + ((options.label.rotate === "right" && options.label.position === "right") || + (options.label.rotate === "left" && options.label.position === "left")) ? + -1: 1 + ); + }) + .attr("x", function(d){ + var x = options.domainMargin[3]; + switch(options.label.position) { + case "right": + x += w(d); + break; + case "bottom": + case "top": + x += w(d)/2; + } + + if (options.label.align === "right") { + return x + self.domainHorizontalLabelWidth - options.label.offset.x * + (options.label.rotate === "right" ? -1: 1); + } + return x + options.label.offset.x; + + }) + .attr("text-anchor", function() { + switch(options.label.align) { + case "start": + case "left": + return "start"; + case "end": + case "right": + return "end"; + default: + return "middle"; + } + }) + .attr("dominant-baseline", function() { return self.verticalDomainLabel ? "middle": "top"; }) + .text(function(d) { return self.formatDate(new Date(d), options.domainLabelFormat); }) + .call(domainRotate) + ; + } + + function domainRotate(selection) { + switch (options.label.rotate) { + case "right": + selection + .attr("transform", function(d) { + var s = "rotate(90), "; + switch(options.label.position) { + case "right": + s += "translate(-" + w(d) + " , -" + w(d) + ")"; + break; + case "left": + s += "translate(0, -" + self.domainHorizontalLabelWidth + ")"; + break; + } + + return s; + }); + break; + case "left": + selection + .attr("transform", function(d) { + var s = "rotate(270), "; + switch(options.label.position) { + case "right": + s += "translate(-" + (w(d) + self.domainHorizontalLabelWidth) + " , " + w(d) + ")"; + break; + case "left": + s += "translate(-" + (self.domainHorizontalLabelWidth) + " , " + self.domainHorizontalLabelWidth + ")"; + break; + } + + return s; + }); + break; + } + } + + // =========================================================================// + // PAINTING DOMAIN SUBDOMAIN CONTENT // + // =========================================================================// + if (options.subDomainTextFormat !== null) { + rect + .append("text") + .attr("class", function(d) { return "subdomain-text" + self.getHighlightClassName(d.t); }) + .attr("x", function(d) { return self.positionSubDomainX(d.t) + options.cellSize/2; }) + .attr("y", function(d) { return self.positionSubDomainY(d.t) + options.cellSize/2; }) + .attr("text-anchor", "middle") + .attr("dominant-baseline", "central") + .text(function(d){ + return self.formatDate(new Date(d.t), options.subDomainTextFormat); + }) + ; + } + + // =========================================================================// + // ANIMATION // + // =========================================================================// + + if (navigationDir !== false) { + domainSvg.transition().duration(options.animationDuration) + .attr("x", function(d){ + return options.verticalOrientation ? 0: self.domainPosition.getPosition(d); + }) + .attr("y", function(d){ + return options.verticalOrientation? self.domainPosition.getPosition(d): 0; + }) + ; + } + + var tempWidth = self.graphDim.width; + var tempHeight = self.graphDim.height; + + if (options.verticalOrientation) { + self.graphDim.height += enteringDomainDim - exitingDomainDim; + } else { + self.graphDim.width += enteringDomainDim - exitingDomainDim; + } + + // At the time of exit, domainsWidth and domainsHeight already automatically shifted + domainSvg.exit().transition().duration(options.animationDuration) + .attr("x", function(d){ + if (options.verticalOrientation) { + return 0; + } else { + switch(navigationDir) { + case self.NAVIGATE_LEFT: + return Math.min(self.graphDim.width, tempWidth); + case self.NAVIGATE_RIGHT: + return -w(d, true); + } + } + }) + .attr("y", function(d){ + if (options.verticalOrientation) { + switch(navigationDir) { + case self.NAVIGATE_LEFT: + return Math.min(self.graphDim.height, tempHeight); + case self.NAVIGATE_RIGHT: + return -h(d, true); + } + } else { + return 0; + } + }) + .remove() + ; + + // Resize the root container + self.resize(); + }; +}; + +CalHeatMap.prototype = { + + /** + * Validate and merge user settings with default settings + * + * @param {object} settings User settings + * @return {bool} False if settings contains error + */ + /* jshint maxstatements:false */ + init: function(settings) { + "use strict"; + + var parent = this; + + var options = parent.options = mergeRecursive(parent.options, settings); + + // Fatal errors + // Stop script execution on error + validateDomainType(); + validateSelector(options.itemSelector, false, "itemSelector"); + + if (parent.allowedDataType.indexOf(options.dataType) === -1) { + throw new Error("The data type '" + options.dataType + "' is not valid data type"); + } + + if (d3.select(options.itemSelector)[0][0] === null) { + throw new Error("The node '" + options.itemSelector + "' specified in itemSelector does not exists"); + } + + try { + validateSelector(options.nextSelector, true, "nextSelector"); + validateSelector(options.previousSelector, true, "previousSelector"); + } catch(error) { + console.log(error.message); + return false; + } + + // If other settings contains error, will fallback to default + + if (!settings.hasOwnProperty("subDomain")) { + this.options.subDomain = getOptimalSubDomain(settings.domain); + } + + if (typeof options.itemNamespace !== "string" || options.itemNamespace === "") { + console.log("itemNamespace can not be empty, falling back to cal-heatmap"); + options.itemNamespace = "cal-heatmap"; + } + + // Don't touch these settings + var s = ["data", "onComplete", "onClick", "afterLoad", "afterLoadData", "afterLoadPreviousDomain", "afterLoadNextDomain"]; + + for (var k in s) { + if (settings.hasOwnProperty(s[k])) { + options[s[k]] = settings[s[k]]; + } + } + + options.subDomainDateFormat = (typeof options.subDomainDateFormat === "string" || typeof options.subDomainDateFormat === "function" ? options.subDomainDateFormat : this._domainType[options.subDomain].format.date); + options.domainLabelFormat = (typeof options.domainLabelFormat === "string" || typeof options.domainLabelFormat === "function" ? options.domainLabelFormat : this._domainType[options.domain].format.legend); + options.subDomainTextFormat = ((typeof options.subDomainTextFormat === "string" && options.subDomainTextFormat !== "") || typeof options.subDomainTextFormat === "function" ? options.subDomainTextFormat : null); + options.domainMargin = expandMarginSetting(options.domainMargin); + options.legendMargin = expandMarginSetting(options.legendMargin); + options.highlight = parent.expandDateSetting(options.highlight); + options.itemName = expandItemName(options.itemName); + options.colLimit = parseColLimit(options.colLimit); + options.rowLimit = parseRowLimit(options.rowLimit); + if (!settings.hasOwnProperty("legendMargin")) { + autoAddLegendMargin(); + } + autoAlignLabel(); + + /** + * Validate that a queryString is valid + * + * @param {Element|string|bool} selector The queryString to test + * @param {bool} canBeFalse Whether false is an accepted and valid value + * @param {string} name Name of the tested selector + * @throws {Error} If the selector is not valid + * @return {bool} True if the selector is a valid queryString + */ + function validateSelector(selector, canBeFalse, name) { + if (((canBeFalse && selector === false) || selector instanceof Element || typeof selector === "string") && selector !== "") { + return true; + } + throw new Error("The " + name + " is not valid"); + } + + /** + * Return the optimal subDomain for the specified domain + * + * @param {string} domain a domain name + * @return {string} the subDomain name + */ + function getOptimalSubDomain(domain) { + switch(domain) { + case "year": + return "month"; + case "month": + return "day"; + case "week": + return "day"; + case "day": + return "hour"; + default: + return "min"; + } + } + + /** + * Ensure that the domain and subdomain are valid + * + * @throw {Error} when domain or subdomain are not valid + * @return {bool} True if domain and subdomain are valid and compatible + */ + function validateDomainType() { + if (!parent._domainType.hasOwnProperty(options.domain) || options.domain === "min" || options.domain.substring(0, 2) === "x_") { + throw new Error("The domain '" + options.domain + "' is not valid"); + } + + if (!parent._domainType.hasOwnProperty(options.subDomain) || options.subDomain === "year") { + throw new Error("The subDomain '" + options.subDomain + "' is not valid"); + } + + if (parent._domainType[options.domain].level <= parent._domainType[options.subDomain].level) { + throw new Error("'" + options.subDomain + "' is not a valid subDomain to '" + options.domain + "'"); + } + + return true; + } + + /** + * Fine-tune the label alignement depending on its position + * + * @return void + */ + function autoAlignLabel() { + // Auto-align label, depending on it's position + if (!settings.hasOwnProperty("label") || (settings.hasOwnProperty("label") && !settings.label.hasOwnProperty("align"))) { + switch(options.label.position) { + case "left": + options.label.align = "right"; + break; + case "right": + options.label.align = "left"; + break; + default: + options.label.align = "center"; + } + + if (options.label.rotate === "left") { + options.label.align = "right"; + } else if (options.label.rotate === "right") { + options.label.align = "left"; + } + } + + if (!settings.hasOwnProperty("label") || (settings.hasOwnProperty("label") && !settings.label.hasOwnProperty("offset"))) { + if (options.label.position === "left" || options.label.position === "right") { + options.label.offset = { + x: 10, + y: 15 + }; + } + } + } + + /** + * If not specified, add some margin around the legend depending on its position + * + * @return void + */ + function autoAddLegendMargin() { + switch(options.legendVerticalPosition) { + case "top": + options.legendMargin[2] = parent.DEFAULT_LEGEND_MARGIN; + break; + case "bottom": + options.legendMargin[0] = parent.DEFAULT_LEGEND_MARGIN; + break; + case "middle": + case "center": + options.legendMargin[options.legendHorizontalPosition === "right" ? 3 : 1] = parent.DEFAULT_LEGEND_MARGIN; + } + } + + /** + * Expand a number of an array of numbers to an usable 4 values array + * + * @param {integer|array} value + * @return {array} array + */ + function expandMarginSetting(value) { + if (typeof value === "number") { + value = [value]; + } + + if (!Array.isArray(value)) { + console.log("Margin only takes an integer or an array of integers"); + value = [0]; + } + + switch(value.length) { + case 1: + return [value[0], value[0], value[0], value[0]]; + case 2: + return [value[0], value[1], value[0], value[1]]; + case 3: + return [value[0], value[1], value[2], value[1]]; + case 4: + return value; + default: + return value.slice(0, 4); + } + } + + /** + * Convert a string to an array like [singular-form, plural-form] + * + * @param {string|array} value Date to convert + * @return {array} An array like [singular-form, plural-form] + */ + function expandItemName(value) { + if (typeof value === "string") { + return [value, value + (value !== "" ? "s" : "")]; + } + + if (Array.isArray(value)) { + if (value.length === 1) { + return [value[0], value[0] + "s"]; + } else if (value.length > 2) { + return value.slice(0, 2); + } + + return value; + } + + return ["item", "items"]; + } + + function parseColLimit(value) { + return value > 0 ? value : null; + } + + function parseRowLimit(value) { + if (value > 0 && options.colLimit > 0) { + console.log("colLimit and rowLimit are mutually exclusive, rowLimit will be ignored"); + return null; + } + return value > 0 ? value : null; + } + + return this._init(); + + }, + + /** + * Convert a keyword or an array of keyword/date to an array of date objects + * + * @param {string|array|Date} value Data to convert + * @return {array} An array of Dates + */ + expandDateSetting: function(value) { + "use strict"; + + if (!Array.isArray(value)) { + value = [value]; + } + + return value.map(function(data) { + if (data === "now") { + return new Date(); + } + if (data instanceof Date) { + return data; + } + return false; + }).filter(function(d) { return d !== false; }); + }, + + /** + * Fill the calendar by coloring the cells + * + * @param array svg An array of html node to apply the transformation to (optional) + * It's used to limit the painting to only a subset of the calendar + * @return void + */ + fill: function(svg) { + "use strict"; + + var parent = this; + var options = parent.options; + + if (arguments.length === 0) { + svg = parent.root.selectAll(".graph-domain"); + } + + var rect = svg + .selectAll("svg").selectAll("g") + .data(function(d) { return parent._domains.get(d); }) + ; + + /** + * Colorize the cell via a style attribute if enabled + */ + function addStyle(element) { + if (parent.legendScale === null) { + return false; + } + + element.attr("fill", function(d) { + if (d.v === null && (options.hasOwnProperty("considerMissingDataAsZero") && !options.considerMissingDataAsZero)) { + if (options.legendColors.hasOwnProperty("base")) { + return options.legendColors.base; + } + } + + if (options.legendColors !== null && options.legendColors.hasOwnProperty("empty") && + (d.v === 0 || (d.v === null && options.hasOwnProperty("considerMissingDataAsZero") && options.considerMissingDataAsZero)) + ) { + return options.legendColors.empty; + } + + if (d.v < 0 && options.legend[0] > 0 && options.legendColors !== null && options.legendColors.hasOwnProperty("overflow")) { + return options.legendColors.overflow; + } + + return parent.legendScale(Math.min(d.v, options.legend[options.legend.length-1])); + }); + } + + rect.transition().duration(options.animationDuration).select("rect") + .attr("class", function(d) { + + var htmlClass = parent.getHighlightClassName(d.t).trim().split(" "); + var pastDate = parent.dateIsLessThan(d.t, new Date()); + var sameDate = parent.dateIsEqual(d.t, new Date()); + + if (parent.legendScale === null || + (d.v === null && (options.hasOwnProperty("considerMissingDataAsZero") && !options.considerMissingDataAsZero) &&!options.legendColors.hasOwnProperty("base")) + ) { + htmlClass.push("graph-rect"); + } + + if (sameDate) { + htmlClass.push("now"); + } else if (!pastDate) { + htmlClass.push("future"); + } + + if (d.v !== null) { + htmlClass.push(parent.Legend.getClass(d.v, (parent.legendScale === null))); + } else if (options.considerMissingDataAsZero && pastDate) { + htmlClass.push(parent.Legend.getClass(0, (parent.legendScale === null))); + } + + if (options.onClick !== null) { + htmlClass.push("hover_cursor"); + } + + return htmlClass.join(" "); + }) + .call(addStyle) + ; + + rect.transition().duration(options.animationDuration).select("title") + .text(function(d) { return parent.getSubDomainTitle(d); }) + ; + + function formatSubDomainText(element) { + if (typeof options.subDomainTextFormat === "function") { + element.text(function(d) { return options.subDomainTextFormat(d.t, d.v); }); + } + } + + /** + * Change the subDomainText class if necessary + * Also change the text, e.g when text is representing the value + * instead of the date + */ + rect.transition().duration(options.animationDuration).select("text") + .attr("class", function(d) { return "subdomain-text" + parent.getHighlightClassName(d.t); }) + .call(formatSubDomainText) + ; + }, + + // =========================================================================// + // EVENTS CALLBACK // + // =========================================================================// + + /** + * Helper method for triggering event callback + * + * @param string eventName Name of the event to trigger + * @param array successArgs List of argument to pass to the callback + * @param boolean skip Whether to skip the event triggering + * @return mixed True when the triggering was skipped, false on error, else the callback function + */ + triggerEvent: function(eventName, successArgs, skip) { + "use strict"; + + if ((arguments.length === 3 && skip) || this.options[eventName] === null) { + return true; + } + + if (typeof this.options[eventName] === "function") { + if (typeof successArgs === "function") { + successArgs = successArgs(); + } + return this.options[eventName].apply(this, successArgs); + } else { + console.log("Provided callback for " + eventName + " is not a function."); + return false; + } + }, + + /** + * Event triggered on a mouse click on a subDomain cell + * + * @param Date d Date of the subdomain block + * @param int itemNb Number of items in that date + */ + onClick: function(d, itemNb) { + "use strict"; + + return this.triggerEvent("onClick", [d, itemNb]); + }, + + /** + * Event triggered after drawing the calendar, byt before filling it with data + */ + afterLoad: function() { + "use strict"; + + return this.triggerEvent("afterLoad"); + }, + + /** + * Event triggered after completing drawing and filling the calendar + */ + onComplete: function() { + "use strict"; + + var response = this.triggerEvent("onComplete", [], this._completed); + this._completed = true; + return response; + }, + + /** + * Event triggered after shifting the calendar one domain back + * + * @param Date start Domain start date + * @param Date end Domain end date + */ + afterLoadPreviousDomain: function(start) { + "use strict"; + + var parent = this; + return this.triggerEvent("afterLoadPreviousDomain", function() { + var subDomain = parent.getSubDomain(start); + return [subDomain.shift(), subDomain.pop()]; + }); + }, + + /** + * Event triggered after shifting the calendar one domain above + * + * @param Date start Domain start date + * @param Date end Domain end date + */ + afterLoadNextDomain: function(start) { + "use strict"; + + var parent = this; + return this.triggerEvent("afterLoadNextDomain", function() { + var subDomain = parent.getSubDomain(start); + return [subDomain.shift(), subDomain.pop()]; + }); + }, + + /** + * Event triggered after loading the leftmost domain allowed by minDate + * + * @param boolean reached True if the leftmost domain was reached + */ + onMinDomainReached: function(reached) { + "use strict"; + + this._minDomainReached = reached; + return this.triggerEvent("onMinDomainReached", [reached]); + }, + + /** + * Event triggered after loading the rightmost domain allowed by maxDate + * + * @param boolean reached True if the rightmost domain was reached + */ + onMaxDomainReached: function(reached) { + "use strict"; + + this._maxDomainReached = reached; + return this.triggerEvent("onMaxDomainReached", [reached]); + }, + + checkIfMinDomainIsReached: function(date, upperBound) { + "use strict"; + + if (this.minDomainIsReached(date)) { + this.onMinDomainReached(true); + } + + if (arguments.length === 2) { + if (this._maxDomainReached && !this.maxDomainIsReached(upperBound)) { + this.onMaxDomainReached(false); + } + } + }, + + checkIfMaxDomainIsReached: function(date, lowerBound) { + "use strict"; + + if (this.maxDomainIsReached(date)) { + this.onMaxDomainReached(true); + } + + if (arguments.length === 2) { + if (this._minDomainReached && !this.minDomainIsReached(lowerBound)) { + this.onMinDomainReached(false); + } + } + }, + + // =========================================================================// + // FORMATTER // + // =========================================================================// + + formatNumber: d3.format(",g"), + + formatDate: function(d, format) { + "use strict"; + + if (arguments.length < 2) { + format = "title"; + } + + if (typeof format === "function") { + return format(d); + } else { + var f = d3.time.format(format); + return f(d); + } + }, + + getSubDomainTitle: function(d) { + "use strict"; + + if (d.v === null && !this.options.considerMissingDataAsZero) { + return (this.options.subDomainTitleFormat.empty).format({ + date: this.formatDate(new Date(d.t), this.options.subDomainDateFormat) + }); + } else { + var value = d.v; + // Consider null as 0 + if (value === null && this.options.considerMissingDataAsZero) { + value = 0; + } + + return (this.options.subDomainTitleFormat.filled).format({ + count: this.formatNumber(value), + name: this.options.itemName[(value !== 1 ? 1: 0)], + connector: this._domainType[this.options.subDomain].format.connector, + date: this.formatDate(new Date(d.t), this.options.subDomainDateFormat) + }); + } + }, + + // =========================================================================// + // DOMAIN NAVIGATION // + // =========================================================================// + + /** + * Shift the calendar one domain forward + * + * The new domain is loaded only if it's not beyond maxDate + * + * @param int n Number of domains to load + * @return bool True if the next domain was loaded, else false + */ + loadNextDomain: function(n) { + "use strict"; + + if (this._maxDomainReached || n === 0) { + return false; + } + + var bound = this.loadNewDomains(this.NAVIGATE_RIGHT, this.getDomain(this.getNextDomain(), n)); + + this.afterLoadNextDomain(bound.end); + this.checkIfMaxDomainIsReached(this.getNextDomain().getTime(), bound.start); + + return true; + }, + + /** + * Shift the calendar one domain backward + * + * The previous domain is loaded only if it's not beyond the minDate + * + * @param int n Number of domains to load + * @return bool True if the previous domain was loaded, else false + */ + loadPreviousDomain: function(n) { + "use strict"; + + if (this._minDomainReached || n === 0) { + return false; + } + + var bound = this.loadNewDomains(this.NAVIGATE_LEFT, this.getDomain(this.getDomainKeys()[0], -n).reverse()); + + this.afterLoadPreviousDomain(bound.start); + this.checkIfMinDomainIsReached(bound.start, bound.end); + + return true; + }, + + loadNewDomains: function(direction, newDomains) { + "use strict"; + + var parent = this; + var backward = direction === this.NAVIGATE_LEFT; + var i = -1; + var total = newDomains.length; + var domains = this.getDomainKeys(); + + function buildSubDomain(d) { + return {t: parent._domainType[parent.options.subDomain].extractUnit(d), v: null}; + } + + // Remove out of bound domains from list of new domains to prepend + while (++i < total) { + if (backward && this.minDomainIsReached(newDomains[i])) { + newDomains = newDomains.slice(0, i+1); + break; + } + if (!backward && this.maxDomainIsReached(newDomains[i])) { + newDomains = newDomains.slice(0, i); + break; + } + } + + newDomains = newDomains.slice(-this.options.range); + + for (i = 0, total = newDomains.length; i < total; i++) { + this._domains.set( + newDomains[i].getTime(), + this.getSubDomain(newDomains[i]).map(buildSubDomain) + ); + + this._domains.remove(backward ? domains.pop() : domains.shift()); + } + + domains = this.getDomainKeys(); + + if (backward) { + newDomains = newDomains.reverse(); + } + + this.paint(direction); + + this.getDatas( + this.options.data, + newDomains[0], + this.getSubDomain(newDomains[newDomains.length-1]).pop(), + function() { + parent.fill(parent.lastInsertedSvg); + } + ); + + return { + start: newDomains[backward ? 0 : 1], + end: domains[domains.length-1] + }; + }, + + /** + * Return whether a date is inside the scope determined by maxDate + * + * @param int datetimestamp The timestamp in ms to test + * @return bool True if the specified date correspond to the calendar upper bound + */ + maxDomainIsReached: function(datetimestamp) { + "use strict"; + + return (this.options.maxDate !== null && (this.options.maxDate.getTime() < datetimestamp)); + }, + + /** + * Return whether a date is inside the scope determined by minDate + * + * @param int datetimestamp The timestamp in ms to test + * @return bool True if the specified date correspond to the calendar lower bound + */ + minDomainIsReached: function (datetimestamp) { + "use strict"; + + return (this.options.minDate !== null && (this.options.minDate.getTime() >= datetimestamp)); + }, + + /** + * Return the list of the calendar's domain timestamp + * + * @return Array a sorted array of timestamp + */ + getDomainKeys: function() { + "use strict"; + + return this._domains.keys() + .map(function(d) { return parseInt(d, 10); }) + .sort(function(a,b) { return a-b; }); + }, + + // =========================================================================// + // POSITIONNING // + // =========================================================================// + + positionSubDomainX: function(d) { + "use strict"; + + var index = this._domainType[this.options.subDomain].position.x(new Date(d)); + return index * this.options.cellSize + index * this.options.cellPadding; + }, + + positionSubDomainY: function(d) { + "use strict"; + + var index = this._domainType[this.options.subDomain].position.y(new Date(d)); + return index * this.options.cellSize + index * this.options.cellPadding; + }, + + getSubDomainColumnNumber: function(d) { + "use strict"; + + if (this.options.rowLimit > 0) { + var i = this._domainType[this.options.subDomain].maxItemNumber; + if (typeof i === "function") { + i = i(d); + } + return Math.ceil(i / this.options.rowLimit); + } + + var j = this._domainType[this.options.subDomain].defaultColumnNumber; + if (typeof j === "function") { + j = j(d); + + } + return this.options.colLimit || j; + }, + + getSubDomainRowNumber: function(d) { + "use strict"; + + if (this.options.colLimit > 0) { + var i = this._domainType[this.options.subDomain].maxItemNumber; + if (typeof i === "function") { + i = i(d); + } + return Math.ceil(i / this.options.colLimit); + } + + var j = this._domainType[this.options.subDomain].defaultRowNumber; + if (typeof j === "function") { + j = j(d); + + } + return this.options.rowLimit || j; + }, + + /** + * Return a classname if the specified date should be highlighted + * + * @param timestamp date Date of the current subDomain + * @return String the highlight class + */ + getHighlightClassName: function(d) { + "use strict"; + + d = new Date(d); + + if (this.options.highlight.length > 0) { + for (var i in this.options.highlight) { + if (this.dateIsEqual(this.options.highlight[i], d)) { + return this.isNow(this.options.highlight[i]) ? " highlight-now": " highlight"; + } + } + } + return ""; + }, + + /** + * Return whether the specified date is now, + * according to the type of subdomain + * + * @param Date d The date to compare + * @return bool True if the date correspond to a subdomain cell + */ + isNow: function(d) { + "use strict"; + + return this.dateIsEqual(d, new Date()); + }, + + /** + * Return whether 2 dates are equals + * This function is subdomain-aware, + * and dates comparison are dependent of the subdomain + * + * @param Date dateA First date to compare + * @param Date dateB Secon date to compare + * @return bool true if the 2 dates are equals + */ + /* jshint maxcomplexity: false */ + dateIsEqual: function(dateA, dateB) { + "use strict"; + + if(!(dateA instanceof Date)) { + dateA = new Date(dateA); + } + + if (!(dateB instanceof Date)) { + dateB = new Date(dateB); + } + + switch(this.options.subDomain) { + case "x_min": + case "min": + return dateA.getFullYear() === dateB.getFullYear() && + dateA.getMonth() === dateB.getMonth() && + dateA.getDate() === dateB.getDate() && + dateA.getHours() === dateB.getHours() && + dateA.getMinutes() === dateB.getMinutes(); + case "x_hour": + case "hour": + return dateA.getFullYear() === dateB.getFullYear() && + dateA.getMonth() === dateB.getMonth() && + dateA.getDate() === dateB.getDate() && + dateA.getHours() === dateB.getHours(); + case "x_day": + case "day": + return dateA.getFullYear() === dateB.getFullYear() && + dateA.getMonth() === dateB.getMonth() && + dateA.getDate() === dateB.getDate(); + case "x_week": + case "week": + return dateA.getFullYear() === dateB.getFullYear() && + this.getWeekNumber(dateA) === this.getWeekNumber(dateB); + case "x_month": + case "month": + return dateA.getFullYear() === dateB.getFullYear() && + dateA.getMonth() === dateB.getMonth(); + default: + return false; + } + }, + + + /** + * Returns wether or not dateA is less than or equal to dateB. This function is subdomain aware. + * Performs automatic conversion of values. + * @param dateA may be a number or a Date + * @param dateB may be a number or a Date + * @returns {boolean} + */ + dateIsLessThan: function(dateA, dateB) { + "use strict"; + + if(!(dateA instanceof Date)) { + dateA = new Date(dateA); + } + + if (!(dateB instanceof Date)) { + dateB = new Date(dateB); + } + + + function normalizedMillis(date, subdomain) { + switch(subdomain) { + case "x_min": + case "min": + return new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes()).getTime(); + case "x_hour": + case "hour": + return new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours()).getTime(); + case "x_day": + case "day": + return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + case "x_week": + case "week": + case "x_month": + case "month": + return new Date(date.getFullYear(), date.getMonth()).getTime(); + default: + return date.getTime(); + } + } + + return normalizedMillis(dateA, this.options.subDomain) < normalizedMillis(dateB, this.options.subDomain); + }, + + + // =========================================================================// + // DATE COMPUTATION // + // =========================================================================// + + /** + * Return the day of the year for the date + * @param Date + * @return int Day of the year [1,366] + */ + getDayOfYear: d3.time.format("%j"), + + /** + * Return the week number of the year + * Monday as the first day of the week + * @return int Week number [0-53] + */ + getWeekNumber: function(d) { + "use strict"; + + var f = this.options.weekStartOnMonday === true ? d3.time.format("%W"): d3.time.format("%U"); + return f(d); + }, + + /** + * Return the week number, relative to its month + * + * @param int|Date d Date or timestamp in milliseconds + * @return int Week number, relative to the month [0-5] + */ + getMonthWeekNumber: function (d) { + "use strict"; + + if (typeof d === "number") { + d = new Date(d); + } + + var monthFirstWeekNumber = this.getWeekNumber(new Date(d.getFullYear(), d.getMonth())); + return this.getWeekNumber(d) - monthFirstWeekNumber - 1; + }, + + /** + * Return the number of weeks in the dates' year + * + * @param int|Date d Date or timestamp in milliseconds + * @return int Number of weeks in the date's year + */ + getWeekNumberInYear: function(d) { + "use strict"; + + if (typeof d === "number") { + d = new Date(d); + } + }, + + /** + * Return the number of days in the date's month + * + * @param int|Date d Date or timestamp in milliseconds + * @return int Number of days in the date's month + */ + getDayCountInMonth: function(d) { + "use strict"; + + return this.getEndOfMonth(d).getDate(); + }, + + /** + * Return the number of days in the date's year + * + * @param int|Date d Date or timestamp in milliseconds + * @return int Number of days in the date's year + */ + getDayCountInYear: function(d) { + "use strict"; + + if (typeof d === "number") { + d = new Date(d); + } + return (new Date(d.getFullYear(), 1, 29).getMonth() === 1) ? 366 : 365; + }, + + /** + * Get the weekday from a date + * + * Return the week day number (0-6) of a date, + * depending on whether the week start on monday or sunday + * + * @param Date d + * @return int The week day number (0-6) + */ + getWeekDay: function(d) { + "use strict"; + + if (this.options.weekStartOnMonday === false) { + return d.getDay(); + } + return d.getDay() === 0 ? 6 : (d.getDay()-1); + }, + + /** + * Get the last day of the month + * @param Date|int d Date or timestamp in milliseconds + * @return Date Last day of the month + */ + getEndOfMonth: function(d) { + "use strict"; + + if (typeof d === "number") { + d = new Date(d); + } + return new Date(d.getFullYear(), d.getMonth()+1, 0); + }, + + /** + * + * @param Date date + * @param int count + * @param string step + * @return Date + */ + jumpDate: function(date, count, step) { + "use strict"; + + var d = new Date(date); + switch(step) { + case "hour": + d.setHours(d.getHours() + count); + break; + case "day": + d.setHours(d.getHours() + count * 24); + break; + case "week": + d.setHours(d.getHours() + count * 24 * 7); + break; + case "month": + d.setMonth(d.getMonth() + count); + break; + case "year": + d.setFullYear(d.getFullYear() + count); + } + + return new Date(d); + }, + + // =========================================================================// + // DOMAIN COMPUTATION // + // =========================================================================// + + /** + * Return all the minutes between 2 dates + * + * @param Date d date A date + * @param int|date range Number of minutes in the range, or a stop date + * @return array An array of minutes + */ + getMinuteDomain: function (d, range) { + "use strict"; + + var start = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()); + var stop = null; + if (range instanceof Date) { + stop = new Date(range.getFullYear(), range.getMonth(), range.getDate(), range.getHours()); + } else { + stop = new Date(+start + range * 1000 * 60); + } + return d3.time.minutes(Math.min(start, stop), Math.max(start, stop)); + }, + + /** + * Return all the hours between 2 dates + * + * @param Date d A date + * @param int|date range Number of hours in the range, or a stop date + * @return array An array of hours + */ + getHourDomain: function (d, range) { + "use strict"; + + var start = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()); + var stop = null; + if (range instanceof Date) { + stop = new Date(range.getFullYear(), range.getMonth(), range.getDate(), range.getHours()); + } else { + stop = new Date(start); + stop.setHours(stop.getHours() + range); + } + + var domains = d3.time.hours(Math.min(start, stop), Math.max(start, stop)); + + // Passing from DST to standard time + // If there are 25 hours, let's compress the duplicate hours + var i = 0; + var total = domains.length; + for(i = 0; i < total; i++) { + if (i > 0 && (domains[i].getHours() === domains[i-1].getHours())) { + this.DSTDomain.push(domains[i].getTime()); + domains.splice(i, 1); + break; + } + } + + // d3.time.hours is returning more hours than needed when changing + // from DST to standard time, because there is really 2 hours between + // 1am and 2am! + if (typeof range === "number" && domains.length > Math.abs(range)) { + domains.splice(domains.length-1, 1); + } + + return domains; + }, + + /** + * Return all the days between 2 dates + * + * @param Date d A date + * @param int|date range Number of days in the range, or a stop date + * @return array An array of weeks + */ + getDayDomain: function (d, range) { + "use strict"; + + var start = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + var stop = null; + if (range instanceof Date) { + stop = new Date(range.getFullYear(), range.getMonth(), range.getDate()); + } else { + stop = new Date(start); + stop = new Date(stop.setDate(stop.getDate() + parseInt(range, 10))); + } + + return d3.time.days(Math.min(start, stop), Math.max(start, stop)); + }, + + /** + * Return all the weeks between 2 dates + * + * @param Date d A date + * @param int|date range Number of minutes in the range, or a stop date + * @return array An array of weeks + */ + getWeekDomain: function (d, range) { + "use strict"; + + var weekStart; + + if (this.options.weekStartOnMonday === false) { + weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate() - d.getDay()); + } else { + if (d.getDay() === 1) { + weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + } else if (d.getDay() === 0) { + weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + weekStart.setDate(weekStart.getDate() - 6); + } else { + weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()-d.getDay()+1); + } + } + + var endDate = new Date(weekStart); + + var stop = range; + if (typeof range !== "object") { + stop = new Date(endDate.setDate(endDate.getDate() + range * 7)); + } + + return (this.options.weekStartOnMonday === true) ? + d3.time.mondays(Math.min(weekStart, stop), Math.max(weekStart, stop)): + d3.time.sundays(Math.min(weekStart, stop), Math.max(weekStart, stop)) + ; + }, + + /** + * Return all the months between 2 dates + * + * @param Date d A date + * @param int|date range Number of months in the range, or a stop date + * @return array An array of months + */ + getMonthDomain: function (d, range) { + "use strict"; + + var start = new Date(d.getFullYear(), d.getMonth()); + var stop = null; + if (range instanceof Date) { + stop = new Date(range.getFullYear(), range.getMonth()); + } else { + stop = new Date(start); + stop = stop.setMonth(stop.getMonth()+range); + } + + return d3.time.months(Math.min(start, stop), Math.max(start, stop)); + }, + + /** + * Return all the years between 2 dates + * + * @param Date d date A date + * @param int|date range Number of minutes in the range, or a stop date + * @return array An array of hours + */ + getYearDomain: function(d, range){ + "use strict"; + + var start = new Date(d.getFullYear(), 0); + var stop = null; + if (range instanceof Date) { + stop = new Date(range.getFullYear(), 0); + } else { + stop = new Date(d.getFullYear()+range, 0); + } + + return d3.time.years(Math.min(start, stop), Math.max(start, stop)); + }, + + /** + * Get an array of domain start dates + * + * @param int|Date date A random date included in the wanted domain + * @param int|Date range Number of dates to get, or a stop date + * @return Array of dates + */ + getDomain: function(date, range) { + "use strict"; + + if (typeof date === "number") { + date = new Date(date); + } + + if (arguments.length < 2) { + range = this.options.range; + } + + switch(this.options.domain) { + case "hour" : + var domains = this.getHourDomain(date, range); + + // Case where an hour is missing, when passing from standard time to DST + // Missing hour is perfectly acceptabl in subDomain, but not in domains + if (typeof range === "number" && domains.length < range) { + if (range > 0) { + domains.push(this.getHourDomain(domains[domains.length-1], 2)[1]); + } else { + domains.shift(this.getHourDomain(domains[0], -2)[0]); + } + } + return domains; + case "day" : + return this.getDayDomain(date, range); + case "week" : + return this.getWeekDomain(date, range); + case "month": + return this.getMonthDomain(date, range); + case "year" : + return this.getYearDomain(date, range); + } + }, + + /* jshint maxcomplexity: false */ + getSubDomain: function(date) { + "use strict"; + + if (typeof date === "number") { + date = new Date(date); + } + + var parent = this; + + /** + * @return int + */ + var computeDaySubDomainSize = function(date, domain) { + switch(domain) { + case "year": + return parent.getDayCountInYear(date); + case "month": + return parent.getDayCountInMonth(date); + case "week": + return 7; + } + }; + + /** + * @return int + */ + var computeMinSubDomainSize = function(date, domain) { + switch (domain) { + case "hour": + return 60; + case "day": + return 60 * 24; + case "week": + return 60 * 24 * 7; + } + }; + + /** + * @return int + */ + var computeHourSubDomainSize = function(date, domain) { + switch(domain) { + case "day": + return 24; + case "week": + return 168; + case "month": + return parent.getDayCountInMonth(date) * 24; + } + }; + + /** + * @return int + */ + var computeWeekSubDomainSize = function(date, domain) { + if (domain === "month") { + var endOfMonth = new Date(date.getFullYear(), date.getMonth()+1, 0); + var endWeekNb = parent.getWeekNumber(endOfMonth); + var startWeekNb = parent.getWeekNumber(new Date(date.getFullYear(), date.getMonth())); + + if (startWeekNb > endWeekNb) { + startWeekNb = 0; + endWeekNb++; + } + + return endWeekNb - startWeekNb + 1; + } else if (domain === "year") { + return parent.getWeekNumber(new Date(date.getFullYear(), 11, 31)); + } + }; + + switch(this.options.subDomain) { + case "x_min": + case "min" : + return this.getMinuteDomain(date, computeMinSubDomainSize(date, this.options.domain)); + case "x_hour": + case "hour" : + return this.getHourDomain(date, computeHourSubDomainSize(date, this.options.domain)); + case "x_day": + case "day" : + return this.getDayDomain(date, computeDaySubDomainSize(date, this.options.domain)); + case "x_week": + case "week" : + return this.getWeekDomain(date, computeWeekSubDomainSize(date, this.options.domain)); + case "x_month": + case "month": + return this.getMonthDomain(date, 12); + } + }, + + /** + * Get the n-th next domain after the calendar newest (rightmost) domain + * @param int n + * @return Date The start date of the wanted domain + */ + getNextDomain: function(n) { + "use strict"; + + if (arguments.length === 0) { + n = 1; + } + return this.getDomain(this.jumpDate(this.getDomainKeys().pop(), n, this.options.domain), 1)[0]; + }, + + /** + * Get the n-th domain before the calendar oldest (leftmost) domain + * @param int n + * @return Date The start date of the wanted domain + */ + getPreviousDomain: function(n) { + "use strict"; + + if (arguments.length === 0) { + n = 1; + } + return this.getDomain(this.jumpDate(this.getDomainKeys().shift(), -n, this.options.domain), 1)[0]; + }, + + + // =========================================================================// + // DATAS // + // =========================================================================// + + /** + * Fetch and interpret data from the datasource + * + * @param string|object source + * @param Date startDate + * @param Date endDate + * @param function callback + * @param function|boolean afterLoad function used to convert the data into a json object. Use true to use the afterLoad callback + * @param updateMode + * + * @return mixed + * - True if there are no data to load + * - False if data are loaded asynchronously + */ + getDatas: function(source, startDate, endDate, callback, afterLoad, updateMode) { + "use strict"; + + var self = this; + if (arguments.length < 5) { + afterLoad = true; + } + if (arguments.length < 6) { + updateMode = this.APPEND_ON_UPDATE; + } + var _callback = function(data) { + if (afterLoad !== false) { + if (typeof afterLoad === "function") { + data = afterLoad(data); + } else if (typeof (self.options.afterLoadData) === "function") { + data = self.options.afterLoadData(data); + } else { + console.log("Provided callback for afterLoadData is not a function."); + } + } else if (self.options.dataType === "csv" || self.options.dataType === "tsv") { + data = this.interpretCSV(data); + } + self.parseDatas(data, updateMode, startDate, endDate); + if (typeof callback === "function") { + callback(); + } + }; + + switch(typeof source) { + case "string": + if (source === "") { + _callback({}); + return true; + } else { + var url = this.parseURI(source, startDate, endDate); + var requestType = "GET"; + if (self.options.dataPostPayload !== null ) { + requestType = "POST"; + } + var payload = null; + if (self.options.dataPostPayload !== null) { + payload = this.parseURI(self.options.dataPostPayload, startDate, endDate); + } + + switch(this.options.dataType) { + case "json": + d3.json(url, _callback).send(requestType, payload); + break; + case "csv": + d3.csv(url, _callback).send(requestType, payload); + break; + case "tsv": + d3.tsv(url, _callback).send(requestType, payload); + break; + case "txt": + d3.text(url, "text/plain", _callback).send(requestType, payload); + break; + } + } + return false; + case "object": + if (source === Object(source)) { + _callback(source); + return false; + } + /* falls through */ + default: + _callback({}); + return true; + } + }, + + /** + * Populate the calendar internal data + * + * @param object data + * @param constant updateMode + * @param Date startDate + * @param Date endDate + * + * @return void + */ + parseDatas: function(data, updateMode, startDate, endDate) { + "use strict"; + + if (updateMode === this.RESET_ALL_ON_UPDATE) { + this._domains.forEach(function(key, value) { + value.forEach(function(element, index, array) { + array[index].v = null; + }); + }); + } + + var temp = {}; + + var extractTime = function(d) { return d.t; }; + + /*jshint forin:false */ + for (var d in data) { + var date = new Date(d*1000); + var domainUnit = this.getDomain(date)[0].getTime(); + + // The current data belongs to a domain that was compressed + // Compress the data for the two duplicate hours into the same hour + if (this.DSTDomain.indexOf(domainUnit) >= 0) { + + // Re-assign all data to the first or the second duplicate hours + // depending on which is visible + if (this._domains.has(domainUnit - 3600 * 1000)) { + domainUnit -= 3600 * 1000; + } + } + + // Skip if data is not relevant to current domain + if (isNaN(d) || !data.hasOwnProperty(d) || !this._domains.has(domainUnit) || !(domainUnit >= +startDate && domainUnit < +endDate)) { + continue; + } + + var subDomainsData = this._domains.get(domainUnit); + + if (!temp.hasOwnProperty(domainUnit)) { + temp[domainUnit] = subDomainsData.map(extractTime); + } + + var index = temp[domainUnit].indexOf(this._domainType[this.options.subDomain].extractUnit(date)); + + if (updateMode === this.RESET_SINGLE_ON_UPDATE) { + subDomainsData[index].v = data[d]; + } else { + if (!isNaN(subDomainsData[index].v)) { + subDomainsData[index].v += data[d]; + } else { + subDomainsData[index].v = data[d]; + } + } + } + }, + + parseURI: function(str, startDate, endDate) { + "use strict"; + + // Use a timestamp in seconds + str = str.replace(/\{\{t:start\}\}/g, startDate.getTime()/1000); + str = str.replace(/\{\{t:end\}\}/g, endDate.getTime()/1000); + + // Use a string date, following the ISO-8601 + str = str.replace(/\{\{d:start\}\}/g, startDate.toISOString()); + str = str.replace(/\{\{d:end\}\}/g, endDate.toISOString()); + + return str; + }, + + interpretCSV: function(data) { + "use strict"; + + var d = {}; + var keys = Object.keys(data[0]); + var i, total; + for (i = 0, total = data.length; i < total; i++) { + d[data[i][keys[0]]] = +data[i][keys[1]]; + } + return d; + }, + + /** + * Handle the calendar layout and dimension + * + * Expand and shrink the container depending on its children dimension + * Also rearrange the children position depending on their dimension, + * and the legend position + * + * @return void + */ + resize: function() { + "use strict"; + + var parent = this; + var options = parent.options; + var legendWidth = options.displayLegend ? (parent.Legend.getDim("width") + options.legendMargin[1] + options.legendMargin[3]) : 0; + var legendHeight = options.displayLegend ? (parent.Legend.getDim("height") + options.legendMargin[0] + options.legendMargin[2]) : 0; + + var graphWidth = parent.graphDim.width - options.domainGutter - options.cellPadding; + var graphHeight = parent.graphDim.height - options.domainGutter - options.cellPadding; + + this.root.transition().duration(options.animationDuration) + .attr("width", function() { + if (options.legendVerticalPosition === "middle" || options.legendVerticalPosition === "center") { + return graphWidth + legendWidth; + } + return Math.max(graphWidth, legendWidth); + }) + .attr("height", function() { + if (options.legendVerticalPosition === "middle" || options.legendVerticalPosition === "center") { + return Math.max(graphHeight, legendHeight); + } + return graphHeight + legendHeight; + }) + ; + + this.root.select(".graph").transition().duration(options.animationDuration) + .attr("y", function() { + if (options.legendVerticalPosition === "top") { + return legendHeight; + } + return 0; + }) + .attr("x", function() { + if ( + (options.legendVerticalPosition === "middle" || options.legendVerticalPosition === "center") && + options.legendHorizontalPosition === "left") { + return legendWidth; + } + return 0; + + }) + ; + }, + + // =========================================================================// + // PUBLIC API // + // =========================================================================// + + /** + * Shift the calendar forward + */ + next: function(n) { + "use strict"; + + if (arguments.length === 0) { + n = 1; + } + return this.loadNextDomain(n); + }, + + /** + * Shift the calendar backward + */ + previous: function(n) { + "use strict"; + + if (arguments.length === 0) { + n = 1; + } + return this.loadPreviousDomain(n); + }, + + /** + * Jump directly to a specific date + * + * JumpTo will scroll the calendar until the wanted domain with the specified + * date is visible. Unless you set reset to true, the wanted domain + * will not necessarily be the first (leftmost) domain of the calendar. + * + * @param Date date Jump to the domain containing that date + * @param bool reset Whether the wanted domain should be the first domain of the calendar + * @param bool True of the calendar was scrolled + */ + jumpTo: function(date, reset) { + "use strict"; + + if (arguments.length < 2) { + reset = false; + } + var domains = this.getDomainKeys(); + var firstDomain = domains[0]; + var lastDomain = domains[domains.length-1]; + + if (date < firstDomain) { + return this.loadPreviousDomain(this.getDomain(firstDomain, date).length); + } else { + if (reset) { + return this.loadNextDomain(this.getDomain(firstDomain, date).length); + } + + if (date > lastDomain) { + return this.loadNextDomain(this.getDomain(lastDomain, date).length); + } + } + + return false; + }, + + /** + * Navigate back to the start date + * + * @since 3.3.8 + * @return void + */ + rewind: function() { + "use strict"; + + this.jumpTo(this.options.start, true); + }, + + /** + * Update the calendar with new data + * + * @param object|string dataSource The calendar's datasource, same type as this.options.data + * @param boolean|function afterLoad Whether to execute afterLoad() on the data. Pass directly a function + * if you don't want to use the afterLoad() callback + */ + update: function(dataSource, afterLoad, updateMode) { + "use strict"; + + if (arguments.length < 2) { + afterLoad = true; + } + if (arguments.length < 3) { + updateMode = this.RESET_ALL_ON_UPDATE; + } + + var domains = this.getDomainKeys(); + var self = this; + this.getDatas( + dataSource, + new Date(domains[0]), + this.getSubDomain(domains[domains.length-1]).pop(), + function() { + self.fill(); + }, + afterLoad, + updateMode + ); + }, + + /** + * Set the legend + * + * @param array legend an array of integer, representing the different threshold value + * @param array colorRange an array of 2 hex colors, for the minimum and maximum colors + */ + setLegend: function() { + "use strict"; + + var oldLegend = this.options.legend.slice(0); + if (arguments.length >= 1 && Array.isArray(arguments[0])) { + this.options.legend = arguments[0]; + } + if (arguments.length >= 2) { + if (Array.isArray(arguments[1]) && arguments[1].length >= 2) { + this.options.legendColors = [arguments[1][0], arguments[1][1]]; + } else { + this.options.legendColors = arguments[1]; + } + } + + if ((arguments.length > 0 && !arrayEquals(oldLegend, this.options.legend)) || arguments.length >= 2) { + this.Legend.buildColors(); + this.fill(); + } + + this.Legend.redraw(this.graphDim.width - this.options.domainGutter - this.options.cellPadding); + }, + + /** + * Remove the legend + * + * @return bool False if there is no legend to remove + */ + removeLegend: function() { + "use strict"; + + if (!this.options.displayLegend) { + return false; + } + this.options.displayLegend = false; + this.Legend.remove(); + return true; + }, + + /** + * Display the legend + * + * @return bool False if the legend was already displayed + */ + showLegend: function() { + "use strict"; + + if (this.options.displayLegend) { + return false; + } + this.options.displayLegend = true; + this.Legend.redraw(this.graphDim.width - this.options.domainGutter - this.options.cellPadding); + return true; + }, + + /** + * Highlight dates + * + * Add a highlight class to a set of dates + * + * @since 3.3.5 + * @param array Array of dates to highlight + * @return bool True if dates were highlighted + */ + highlight: function(args) { + "use strict"; + + if ((this.options.highlight = this.expandDateSetting(args)).length > 0) { + this.fill(); + return true; + } + return false; + }, + + /** + * Destroy the calendar + * + * Usage: cal = cal.destroy(); + * + * @since 3.3.6 + * @param function A callback function to trigger after destroying the calendar + * @return null + */ + destroy: function(callback) { + "use strict"; + + this.root.transition().duration(this.options.animationDuration) + .attr("width", 0) + .attr("height", 0) + .remove() + .each("end", function() { + if (typeof callback === "function") { + callback(); + } else if (typeof callback !== "undefined") { + console.log("Provided callback for destroy() is not a function."); + } + }) + ; + + return null; + }, + + getSVG: function() { + "use strict"; + + var styles = { + ".cal-heatmap-container": {}, + ".graph": {}, + ".graph-rect": {}, + "rect.highlight": {}, + "rect.now": {}, + "rect.highlight-now": {}, + "text.highlight": {}, + "text.now": {}, + "text.highlight-now": {}, + ".domain-background": {}, + ".graph-label": {}, + ".subdomain-text": {}, + ".q0": {}, + ".qi": {} + }; + + for (var j = 1, total = this.options.legend.length+1; j <= total; j++) { + styles[".q" + j] = {}; + } + + var root = this.root; + + var whitelistStyles = [ + // SVG specific properties + "stroke", "stroke-width", "stroke-opacity", "stroke-dasharray", "stroke-dashoffset", "stroke-linecap", "stroke-miterlimit", + "fill", "fill-opacity", "fill-rule", + "marker", "marker-start", "marker-mid", "marker-end", + "alignement-baseline", "baseline-shift", "dominant-baseline", "glyph-orientation-horizontal", "glyph-orientation-vertical", "kerning", "text-anchor", + "shape-rendering", + + // Text Specific properties + "text-transform", "font-family", "font", "font-size", "font-weight" + ]; + + var filterStyles = function(attribute, property, value) { + if (whitelistStyles.indexOf(property) !== -1) { + styles[attribute][property] = value; + } + }; + + var getElement = function(e) { + return root.select(e)[0][0]; + }; + + /* jshint forin:false */ + for (var element in styles) { + if (!styles.hasOwnProperty(element)) { + continue; + } + + var dom = getElement(element); + + if (dom === null) { + continue; + } + + // The DOM Level 2 CSS way + /* jshint maxdepth: false */ + if ("getComputedStyle" in window) { + var cs = getComputedStyle(dom, null); + if (cs.length !== 0) { + for (var i = 0; i < cs.length; i++) { + filterStyles(element, cs.item(i), cs.getPropertyValue(cs.item(i))); + } + + // Opera workaround. Opera doesn"t support `item`/`length` + // on CSSStyleDeclaration. + } else { + for (var k in cs) { + if (cs.hasOwnProperty(k)) { + filterStyles(element, k, cs[k]); + } + } + } + + // The IE way + } else if ("currentStyle" in dom) { + var css = dom.currentStyle; + for (var p in css) { + filterStyles(element, p, css[p]); + } + } + } + + var string = ""; + string += new XMLSerializer().serializeToString(this.root[0][0]); + string += ""; + + return string; + } +}; + +// =========================================================================// +// DOMAIN POSITION COMPUTATION // +// =========================================================================// + +/** + * Compute the position of a domain, relative to the calendar + */ +var DomainPosition = function() { + "use strict"; + + this.positions = d3.map(); +}; + +DomainPosition.prototype.getPosition = function(d) { + "use strict"; + + return this.positions.get(d); +}; + +DomainPosition.prototype.getPositionFromIndex = function(i) { + "use strict"; + + var domains = this.getKeys(); + return this.positions.get(domains[i]); +}; + +DomainPosition.prototype.getLast = function() { + "use strict"; + + var domains = this.getKeys(); + return this.positions.get(domains[domains.length-1]); +}; + +DomainPosition.prototype.setPosition = function(d, dim) { + "use strict"; + + this.positions.set(d, dim); +}; + +DomainPosition.prototype.shiftRightBy = function(exitingDomainDim) { + "use strict"; + + this.positions.forEach(function(key, value) { + this.set(key, value - exitingDomainDim); + }); + + var domains = this.getKeys(); + this.positions.remove(domains[0]); +}; + +DomainPosition.prototype.shiftLeftBy = function(enteringDomainDim) { + "use strict"; + + this.positions.forEach(function(key, value) { + this.set(key, value + enteringDomainDim); + }); + + var domains = this.getKeys(); + this.positions.remove(domains[domains.length-1]); +}; + +DomainPosition.prototype.getKeys = function() { + "use strict"; + + return this.positions.keys().sort(function(a, b) { + return parseInt(a, 10) - parseInt(b, 10); + }); +}; + +// =========================================================================// +// LEGEND // +// =========================================================================// + +var Legend = function(calendar) { + "use strict"; + + this.calendar = calendar; + this.computeDim(); + + if (calendar.options.legendColors !== null) { + this.buildColors(); + } +}; + +Legend.prototype.computeDim = function() { + "use strict"; + + var options = this.calendar.options; // Shorter accessor for variable name mangling when minifying + this.dim = { + width: + options.legendCellSize * (options.legend.length+1) + + options.legendCellPadding * (options.legend.length), + height: + options.legendCellSize + }; +}; + +Legend.prototype.remove = function() { + "use strict"; + + this.calendar.root.select(".graph-legend").remove(); + this.calendar.resize(); +}; + +Legend.prototype.redraw = function(width) { + "use strict"; + + if (!this.calendar.options.displayLegend) { + return false; + } + + var parent = this; + var calendar = this.calendar; + var legend = calendar.root; + var legendItem; + var options = calendar.options; // Shorter accessor for variable name mangling when minifying + + this.computeDim(); + + var _legend = options.legend.slice(0); + _legend.push(_legend[_legend.length-1]+1); + + var legendElement = calendar.root.select(".graph-legend"); + if (legendElement[0][0] !== null) { + legend = legendElement; + legendItem = legend + .select("g") + .selectAll("rect").data(_legend) + ; + } else { + // Creating the new legend DOM if it doesn't already exist + legend = options.legendVerticalPosition === "top" ? legend.insert("svg", ".graph") : legend.append("svg"); + + legend + .attr("x", getLegendXPosition()) + .attr("y", getLegendYPosition()) + ; + + legendItem = legend + .attr("class", "graph-legend") + .attr("height", parent.getDim("height")) + .attr("width", parent.getDim("width")) + .append("g") + .selectAll().data(_legend) + ; + } + + legendItem + .enter() + .append("rect") + .call(legendCellLayout) + .attr("class", function(d){ return calendar.Legend.getClass(d, (calendar.legendScale === null)); }) + .attr("fill-opacity", 0) + .call(function(selection) { + if (calendar.legendScale !== null && options.legendColors !== null && options.legendColors.hasOwnProperty("base")) { + selection.attr("fill", options.legendColors.base); + } + }) + .append("title") + ; + + legendItem.exit().transition().duration(options.animationDuration) + .attr("fill-opacity", 0) + .remove(); + + legendItem.transition().delay(function(d, i) { return options.animationDuration * i/10; }) + .call(legendCellLayout) + .attr("fill-opacity", 1) + .call(function(element) { + element.attr("fill", function(d, i) { + if (calendar.legendScale === null) { + return ""; + } + + if (i === 0) { + return calendar.legendScale(d - 1); + } + return calendar.legendScale(options.legend[i-1]); + }); + + element.attr("class", function(d) { return calendar.Legend.getClass(d, (calendar.legendScale === null)); }); + }) + ; + + function legendCellLayout(selection) { + selection + .attr("width", options.legendCellSize) + .attr("height", options.legendCellSize) + .attr("x", function(d, i) { + return i * (options.legendCellSize + options.legendCellPadding); + }) + ; + } + + legendItem.select("title").text(function(d, i) { + if (i === 0) { + return (options.legendTitleFormat.lower).format({ + min: options.legend[i], + name: options.itemName[1] + }); + } else if (i === _legend.length-1) { + return (options.legendTitleFormat.upper).format({ + max: options.legend[i-1], + name: options.itemName[1] + }); + } else { + return (options.legendTitleFormat.inner).format({ + down: options.legend[i-1], + up: options.legend[i], + name: options.itemName[1] + }); + } + }) + ; + + legend.transition().duration(options.animationDuration) + .attr("x", getLegendXPosition()) + .attr("y", getLegendYPosition()) + .attr("width", parent.getDim("width")) + .attr("height", parent.getDim("height")) + ; + + legend.select("g").transition().duration(options.animationDuration) + .attr("transform", function() { + if (options.legendOrientation === "vertical") { + return "rotate(90 " + options.legendCellSize/2 + " " + options.legendCellSize/2 + ")"; + } + return ""; + }) + ; + + function getLegendXPosition() { + switch(options.legendHorizontalPosition) { + case "right": + if (options.legendVerticalPosition === "center" || options.legendVerticalPosition === "middle") { + return width + options.legendMargin[3]; + } + return width - parent.getDim("width") - options.legendMargin[1]; + case "middle": + case "center": + return Math.round(width/2 - parent.getDim("width")/2); + default: + return options.legendMargin[3]; + } + } + + function getLegendYPosition() { + if (options.legendVerticalPosition === "bottom") { + return calendar.graphDim.height + options.legendMargin[0] - options.domainGutter - options.cellPadding; + } + return options.legendMargin[0]; + } + + calendar.resize(); +}; + +/** + * Return the dimension of the legend + * + * Takes into account rotation + * + * @param string axis Width or height + * @return int height or width in pixels + */ +Legend.prototype.getDim = function(axis) { + "use strict"; + + var isHorizontal = (this.calendar.options.legendOrientation === "horizontal"); + + switch(axis) { + case "width": + return this.dim[isHorizontal ? "width": "height"]; + case "height": + return this.dim[isHorizontal ? "height": "width"]; + } +}; + +Legend.prototype.buildColors = function() { + "use strict"; + + var options = this.calendar.options; // Shorter accessor for variable name mangling when minifying + + if (options.legendColors === null) { + this.calendar.legendScale = null; + return false; + } + + var _colorRange = []; + + if (Array.isArray(options.legendColors)) { + _colorRange = options.legendColors; + } else if (options.legendColors.hasOwnProperty("min") && options.legendColors.hasOwnProperty("max")) { + _colorRange = [options.legendColors.min, options.legendColors.max]; + } else { + options.legendColors = null; + return false; + } + + var _legend = options.legend.slice(0); + + if (_legend[0] > 0) { + _legend.unshift(0); + } else if (_legend[0] < 0) { + // Let's guess the leftmost value, it we have to add one + _legend.unshift(_legend[0] - (_legend[_legend.length-1] - _legend[0])/_legend.length); + } + + var colorScale = d3.scale.linear() + .range(_colorRange) + .interpolate(d3.interpolateHcl) + .domain([d3.min(_legend), d3.max(_legend)]) + ; + + var legendColors = _legend.map(function(element) { return colorScale(element); }); + this.calendar.legendScale = d3.scale.threshold().domain(options.legend).range(legendColors); + + return true; +}; + +/** + * Return the classname on the legend for the specified value + * + * @param integer n Value associated to a date + * @param bool withCssClass Whether to display the css class used to style the cell. + * Disabling will allow styling directly via html fill attribute + * + * @return string Classname according to the legend + */ +Legend.prototype.getClass = function(n, withCssClass) { + "use strict"; + + if (n === null || isNaN(n)) { + return ""; + } + + var index = [this.calendar.options.legend.length + 1]; + + for (var i = 0, total = this.calendar.options.legend.length-1; i <= total; i++) { + + if (this.calendar.options.legend[0] > 0 && n < 0) { + index = ["1", "i"]; + break; + } + + if (n <= this.calendar.options.legend[i]) { + index = [i+1]; + break; + } + } + + if (n === 0) { + index.push(0); + } + + index.unshift(""); + return (index.join(" r") + (withCssClass ? index.join(" q"): "")).trim(); +}; + +/** + * Sprintf like function + * @source http://stackoverflow.com/a/4795914/805649 + * @return String + */ +String.prototype.format = function () { + "use strict"; + + var formatted = this; + for (var prop in arguments[0]) { + if (arguments[0].hasOwnProperty(prop)) { + var regexp = new RegExp("\\{" + prop + "\\}", "gi"); + formatted = formatted.replace(regexp, arguments[0][prop]); + } + } + return formatted; +}; + +/** + * #source http://stackoverflow.com/a/383245/805649 + */ +function mergeRecursive(obj1, obj2) { + "use strict"; + + /*jshint forin:false */ + for (var p in obj2) { + try { + // Property in destination object set; update its value. + if (obj2[p].constructor === Object) { + obj1[p] = mergeRecursive(obj1[p], obj2[p]); + } else { + obj1[p] = obj2[p]; + } + } catch(e) { + // Property in destination object not set; create it and set its value. + obj1[p] = obj2[p]; + } + } + + return obj1; +} + +/** + * Check if 2 arrays are equals + * + * @link http://stackoverflow.com/a/14853974/805649 + * @param array array the array to compare to + * @return bool true of the 2 arrays are equals + */ +function arrayEquals(arrayA, arrayB) { + "use strict"; + + // if the other array is a falsy value, return + if (!arrayB || !arrayA) { + return false; + } + + // compare lengths - can save a lot of time + if (arrayA.length !== arrayB.length) { + return false; + } + + for (var i = 0; i < arrayA.length; i++) { + // Check if we have nested arrays + if (arrayA[i] instanceof Array && arrayB[i] instanceof Array) { + // recurse into the nested arrays + if (!arrayEquals(arrayA[i], arrayB[i])) { + return false; + } + } + else if (arrayA[i] !== arrayB[i]) { + // Warning - two different object instances will never be equal: {x:20} != {x:20} + return false; + } + } + return true; +} + +/** + * AMD Loader + */ +if (typeof define === "function" && define.amd) { + define(["d3"], function() { + "use strict"; + + return CalHeatMap; + }); +} else if (typeof module === "object" && module.exports) { + module.exports = CalHeatMap; +} else { + window.CalHeatMap = CalHeatMap; +} diff --git a/web_widget_heatmap/static/src/css/heatmap_widget.css b/web_widget_heatmap/static/src/css/heatmap_widget.css new file mode 100644 index 00000000..bddca11f --- /dev/null +++ b/web_widget_heatmap/static/src/css/heatmap_widget.css @@ -0,0 +1,12 @@ +/* Copyright 2019 Dinar Gabbasov + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ +.heatmap_widget { + width: 100%; + padding: 10px 0px; +} +.o_field_heatmap { + width: 100%; +} +.o_field_heatmap .cal-heatmap-container { + margin: 0 auto; +} diff --git a/web_widget_heatmap/static/src/js/heatmap_widget.js b/web_widget_heatmap/static/src/js/heatmap_widget.js new file mode 100644 index 00000000..805378cc --- /dev/null +++ b/web_widget_heatmap/static/src/js/heatmap_widget.js @@ -0,0 +1,194 @@ +/* + Copyright 2019 Dinar Gabbasov + License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +*/ + +odoo.define('web_widget_heatmap.widget', function (require) { + "use strict"; + + var AbstractField = require('web.AbstractField'); + var registry = require('web.field_registry'); + + + var HeatMapWidget = AbstractField.extend({ + + fieldsToFetch: { + display_name: {type: 'char'}, + create_date: {type: 'datetime'}, + }, + + template: 'HeatMapWidget', + + supportedFieldTypes: ['one2many', 'many2many'], + + description: "", + + cssLibs: [ + '/web/static/lib/nvd3/nv.d3.css', + '/web_widget_heatmap/static/lib/css/cal-heatmap.css' + ], + + jsLibs: [ + '/web/static/lib/nvd3/d3.v3.js', + '/web/static/lib/nvd3/nv.d3.js', + '/web/static/src/js/libs/nvd3.js', + '/web_widget_heatmap/static/lib/js/cal-heatmap.js' + ], + + /** + * Calculate and get the maximum date (up to which date the grid will be built). + * Display of control buttons depends on the maximum date. + * @param {Object} options + * @returns {Date} max_date + */ + get_max_date: function(options) { + var max_date = new Date(options.start_date); + var range = this.nodeOptions ? this.nodeOptions.range : options.range; + var domain = this.nodeOptions ? this.nodeOptions.domain : options.domain; + if (domain === 'hour') { + max_date.setHours(max_date.getHours() + range); + } else if (domain === 'day') { + max_date.setDate(max_date.getDate() + range); + } else if (domain === 'week') { + max_date.setDate(max_date.getDate() + (range * 7)); + } else if (domain === 'month') { + max_date.setMonth(max_date.getMonth() + range); + } else if (domain === 'year') { + max_date.setFullYear(max_date.getFullYear() + range); + } + if (max_date > options.end_date) { + return max_date; + } + return options.end_date; + }, + + /** + * Generate widget options by field data + * @param {Array} elements + * @returns {Object} widget options + */ + generate_element_options: function(elements) { + var start_date = elements.length ? elements[0].create_date.toDate() : new Date(); + var timestamps = elements.map(function(el) { + return el.create_date.unix(); + }); + var domain = "day"; + var range = 16; + var end_date = elements.length ? elements[elements.length - 1].create_date.toDate() : null; + var max_date = this.get_max_date({ + start_date: start_date, + end_date: end_date, + domain: domain, + range: range + }); + var controls = false; + if (max_date < end_date) { + controls = true; + } + return { + start: start_date, + data: _.chain(timestamps).countBy().value(), + dataType: 'json', + minDate: start_date, + maxDate: max_date, + controls: controls, + range: range, + domain: domain, + subDomain: "hour", + domainGutter: 0, + highlight: "now", + onClick: this.onClickHeatMap.bind(this), + label: { + position: "top" + } + }; + }, + + /** + * Render the view + * @private + */ + _render: function () { + var self = this; + var elements = this.value ? _.pluck(this.value.data, 'data') : []; + var options = this.generate_element_options(elements); + this.controls = options.controls; + this.renderElement(); + var nodeOptions = this.nodeOptions || {}; + this.heatmap_options = _.extend(options, {itemSelector: this.$el.find('.o_field_heatmap')[0]}); + _.each(nodeOptions, function(value, key) { + self.heatmap_options[key] = value; + }); + this.heatmap = new CalHeatMap(); + this.heatmap.init(this.heatmap_options); + }, + + /** + * Renders the element and add new events. + */ + renderElement: function() { + this._super(); + this.$el.find('.next').click(this.nextHeatMap.bind(this)); + this.$el.find('.previous').click(this.previousHeatMap.bind(this)); + }, + + /** + * Show next heatmap grid + */ + nextHeatMap: function() { + this.heatmap.next(); + }, + + /** + * Show previous heatmap grid + */ + previousHeatMap: function() { + this.heatmap.previous(); + }, + + /** + * Find the records that match the selected date and show in the tree view + * @param {Date} date + * @returns {jQuery.Deferred} Action loaded + */ + onClickHeatMap: function(date) { + date = moment(date); + var options = this.heatmap_options; + var elements = this.value ? _.pluck(this.value.data, 'data') : []; + if (elements && elements.length) { + // TODO: make faster + var current_elements_ids = elements.filter(function(el) { + var duration = moment.duration(date.diff(el.create_date)); + if (options.domain === 'hour') { + return duration.years() === 0 && duration.months() === 0 && duration.weeks() === 0 && duration.days() === 0 && duration.hours() === 0; + } else if (options.domain === 'day') { + return duration.years() === 0 && duration.months() === 0 && duration.weeks() === 0 && duration.days() === 0; + } else if (options.domain === 'week') { + return duration.years() === 0 && duration.months() === 0 && duration.weeks() === 0; + } else if (options.domain === 'month') { + return duration.years() === 0 && duration.months() === 0; + } else if (options.domain === 'year') { + return duration.years() === 0; + } + return false; + }).map(function(el) { + return el.id; + }); + var action = { + name: date.format('YYYY-MM-DD HH:mm'), + type: 'ir.actions.act_window', + res_model: this.value.model, + view_mode: 'list,form', + views: [[false, 'list'], [false, 'form']], + view_type: 'list', + domain: [['id', 'in', current_elements_ids]], + }; + return this.do_action(action); + } + }, + }); + + registry.add('heatmap', HeatMapWidget); + + return HeatMapWidget; +}); diff --git a/web_widget_heatmap/static/src/xml/base.xml b/web_widget_heatmap/static/src/xml/base.xml new file mode 100644 index 00000000..0959e32d --- /dev/null +++ b/web_widget_heatmap/static/src/xml/base.xml @@ -0,0 +1,14 @@ + + + + +
+ + + + + +
+
+
diff --git a/web_widget_heatmap/views/web_widget_heatmap_template.xml b/web_widget_heatmap/views/web_widget_heatmap_template.xml new file mode 100644 index 00000000..49bcc7aa --- /dev/null +++ b/web_widget_heatmap/views/web_widget_heatmap_template.xml @@ -0,0 +1,13 @@ + + + + + + +