From 1856d180924f552b481d641d05bad5935536cfc0 Mon Sep 17 00:00:00 2001 From: pratu16x7 Date: Fri, 28 Jul 2017 10:51:10 +0530 Subject: [PATCH 1/5] [graphs] c3 data mapper --- frappe/public/js/frappe/form/dashboard.js | 24 +---- frappe/public/js/frappe/ui/graphs.js | 98 +++++++++++++++++-- .../js/frappe/views/reports/query_report.js | 19 ++-- 3 files changed, 103 insertions(+), 38 deletions(-) diff --git a/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js index ba3e17ac9c..38ee1bbb70 100644 --- a/frappe/public/js/frappe/form/dashboard.js +++ b/frappe/public/js/frappe/form/dashboard.js @@ -388,16 +388,14 @@ frappe.ui.form.Dashboard = Class.extend({ return indicator; }, - //graphs + // graphs setup_graph: function() { var me = this; - var method = this.data.graph_method; var args = { doctype: this.frm.doctype, docname: this.frm.doc.name, }; - $.extend(args, this.data.graph_method_args); frappe.call({ @@ -421,29 +419,9 @@ frappe.ui.form.Dashboard = Class.extend({ mode: 'line', height: 140 }); - new frappe.ui.Graph(args); }, - setup_chart: function(opts) { - var me = this; - - this.graph_area.removeClass('hidden'); - - $.extend(opts, { - wrapper: me.graph_area, - padding: { - right: 30, - bottom: 30 - } - }); - - this.chart = new frappe.ui.Chart(opts); - if(this.chart) { - this.show(); - this.chart.set_chart_size(me.wrapper.width() - 60); - } - }, show: function() { this.section.removeClass('hidden'); } diff --git a/frappe/public/js/frappe/ui/graphs.js b/frappe/public/js/frappe/ui/graphs.js index f45ced98ba..3c90ec411c 100644 --- a/frappe/public/js/frappe/ui/graphs.js +++ b/frappe/public/js/frappe/ui/graphs.js @@ -533,6 +533,11 @@ frappe.ui.PercentageGraph = class PercentageGraph extends frappe.ui.Graph { return total; }); + if(!this.x.colors) { + this.x.colors = ['green', 'blue', 'purple', 'red', 'orange', + 'yellow', 'lightblue', 'lightgreen']; + } + // Calculate x unit distances for tooltips } @@ -556,14 +561,93 @@ frappe.ui.PercentageGraph = class PercentageGraph extends frappe.ui.Graph { make_tooltip() { } show_summary() { - let values = this.x.formatted.length > 0 ? this.x.formatted : this.x.values; + let x_values = this.x.formatted && this.x.formatted.length > 0 + ? this.x.formatted : this.x.values; this.x.totals.map((d, i) => { - this.$stats_container.append($(`
- - ${values[i]}: - ${d} - -
`)); + if(d) { + this.$stats_container.append($(`
+ + ${x_values[i]}: + ${d} + +
`)); + } }); } }; + +frappe.provide("frappe.ui.graphs"); + +frappe.ui.graphs.map_c3 = function(chart) { + if (chart.data) { + let data = chart.data; + let mode = chart.chart_type || 'line'; + if(mode === 'pie') { + mode = 'percentage'; + } + + let x = {}, y = []; + + if(data.columns) { + let columns = data.columns; + + x.values = columns.filter(col => { + return col[0] === data.x; + })[0]; + + if(x.values && x.values.length) { + let dataset_length = x.values.length; + let dirty = false; + columns.map(col => { + if(col[0] !== data.x) { + if(col.length === dataset_length) { + let title = col[0]; + col.splice(0, 1); + y.push({ + title: title, + values: col, + color: 'blue' + }); + } else { + dirty = true; + } + } + }) + + if(dirty) { + return; + } + + x.values.splice(0, 1); + + return { + mode: mode, + y: y, + x: x + } + + } + } else if(data.rows) { + let rows = data.rows; + x.values = rows[0]; + + rows.map((row, i) => { + if(i === 0) { + x.values = row; + } else { + y.push({ + title: 'data' + i, + values: row, + color: 'blue' + }) + } + }); + + return { + mode: mode, + y: y, + x: x + } + } + } +} diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index 6b54979d1a..7cac0244ef 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -3,6 +3,7 @@ frappe.provide("frappe.views"); frappe.provide("frappe.query_reports"); +frappe.provide("frappe.ui.graphs"); frappe.standard_pages["query-report"] = function() { var wrapper = frappe.container.add_page('query-report'); @@ -928,22 +929,24 @@ frappe.views.QueryReport = Class.extend({ setup_chart: function(res) { this.chart_area.toggle(false); + let chart, g; if (this.get_query_report_opts().get_chart_data) { - var opts = this.get_query_report_opts().get_chart_data(res.columns, res.result); + chart = this.get_query_report_opts().get_chart_data(res.columns, res.result); } else if (res.chart) { - var opts = res.chart; + chart = res.chart; } else { return; } - $.extend(opts, { - wrapper: this.chart_area, - }); + let args = frappe.ui.graphs.map_c3(chart); + this.chart_area.empty().toggle(true); - this.chart = new frappe.ui.Chart(opts); - if(this.chart && opts.data && opts.data.rows && opts.data.rows.length) { - this.chart_area.toggle(true); + if(args) { + $.extend(args, { + parent: this.chart_area, + }); + g = new frappe.ui.Graph(args); } }, From 8718b7702e3d83fbb5e7703084284cbe525152e8 Mon Sep 17 00:00:00 2001 From: pratu16x7 Date: Sat, 29 Jul 2017 03:41:17 +0530 Subject: [PATCH 2/5] [graphs] tooltip object --- frappe/public/css/graphs.css | 130 +++--- frappe/public/js/frappe/ui/graphs.js | 295 +++++++++---- .../js/frappe/views/reports/query_report.js | 4 + frappe/public/less/graphs.less | 400 +++++++++--------- 4 files changed, 487 insertions(+), 342 deletions(-) diff --git a/frappe/public/css/graphs.css b/frappe/public/css/graphs.css index a9fdf62dc9..3d3f269b0c 100644 --- a/frappe/public/css/graphs.css +++ b/frappe/public/css/graphs.css @@ -2,9 +2,10 @@ .graph-container .graph-focus-margin { margin: 0px 5%; } -.graph-container .graph-graphics { +.graph-container .graphics { margin-top: 10px; - padding: 10px 0px; + padding-top: 10px; + padding-bottom: 10px; position: relative; } .graph-container .graph-stats-group { @@ -43,22 +44,19 @@ .graph-container .line-graph .axis line { stroke: rgba(27, 31, 35, 0.1); } -.graph-container .percentage-graph { - margin-top: 35px; -} .graph-container .percentage-graph .progress { margin-bottom: 0px; } -.graph-container .graph-data-points circle { +.graph-container .data-points circle { stroke: #fff; stroke-width: 2; } -.graph-container .graph-data-points path { +.graph-container .data-points path { fill: none; stroke-opacity: 1; stroke-width: 2px; } -.graph-container line.graph-dashed { +.graph-container line.dashed { stroke-dasharray: 5,3; } .graph-container .tick.x-axis-label { @@ -73,7 +71,7 @@ .graph-container .tick .x-value-text { text-anchor: middle; } -.graph-container .graph-svg-tip { +.graph-svg-tip { position: absolute; z-index: 99999; padding: 10px; @@ -83,12 +81,12 @@ background: rgba(0, 0, 0, 0.8); border-radius: 3px; } -.graph-container .graph-svg-tip.comparison { +.graph-svg-tip.comparison { padding: 0; text-align: left; pointer-events: none; } -.graph-container .graph-svg-tip.comparison .title { +.graph-svg-tip.comparison .title { display: block; padding: 10px; margin: 0; @@ -96,28 +94,28 @@ line-height: 1; pointer-events: none; } -.graph-container .graph-svg-tip.comparison ul { +.graph-svg-tip.comparison ul { margin: 0; white-space: nowrap; list-style: none; } -.graph-container .graph-svg-tip.comparison li { +.graph-svg-tip.comparison li { display: inline-block; padding: 5px 10px; } -.graph-container .graph-svg-tip ul, -.graph-container .graph-svg-tip ol { +.graph-svg-tip ul, +.graph-svg-tip ol { padding-left: 0; display: flex; } -.graph-container .graph-svg-tip ul.data-point-list li { +.graph-svg-tip ul.data-point-list li { min-width: 90px; flex: 1; } -.graph-container .graph-svg-tip strong { +.graph-svg-tip strong { color: #dfe2e5; } -.graph-container .graph-svg-tip::after { +.graph-svg-tip .svg-pointer { position: absolute; bottom: -10px; left: 50%; @@ -128,147 +126,147 @@ border: 5px solid transparent; border-top-color: rgba(0, 0, 0, 0.8); } -.graph-container .stroke.grey { +.stroke.grey { stroke: #F0F4F7; } -.graph-container .stroke.blue { +.stroke.blue { stroke: #5e64ff; } -.graph-container .stroke.red { +.stroke.red { stroke: #ff5858; } -.graph-container .stroke.light-green { +.stroke.light-green { stroke: #98d85b; } -.graph-container .stroke.green { +.stroke.green { stroke: #28a745; } -.graph-container .stroke.orange { +.stroke.orange { stroke: #ffa00a; } -.graph-container .stroke.purple { +.stroke.purple { stroke: #743ee2; } -.graph-container .stroke.darkgrey { +.stroke.darkgrey { stroke: #b8c2cc; } -.graph-container .stroke.black { +.stroke.black { stroke: #36414C; } -.graph-container .stroke.yellow { +.stroke.yellow { stroke: #FEEF72; } -.graph-container .stroke.light-blue { +.stroke.light-blue { stroke: #7CD6FD; } -.graph-container .stroke.lightblue { +.stroke.lightblue { stroke: #7CD6FD; } -.graph-container .fill.grey { +.fill.grey { fill: #F0F4F7; } -.graph-container .fill.blue { +.fill.blue { fill: #5e64ff; } -.graph-container .fill.red { +.fill.red { fill: #ff5858; } -.graph-container .fill.light-green { +.fill.light-green { fill: #98d85b; } -.graph-container .fill.green { +.fill.green { fill: #28a745; } -.graph-container .fill.orange { +.fill.orange { fill: #ffa00a; } -.graph-container .fill.purple { +.fill.purple { fill: #743ee2; } -.graph-container .fill.darkgrey { +.fill.darkgrey { fill: #b8c2cc; } -.graph-container .fill.black { +.fill.black { fill: #36414C; } -.graph-container .fill.yellow { +.fill.yellow { fill: #FEEF72; } -.graph-container .fill.light-blue { +.fill.light-blue { fill: #7CD6FD; } -.graph-container .fill.lightblue { +.fill.lightblue { fill: #7CD6FD; } -.graph-container .background.grey { +.background.grey { background: #F0F4F7; } -.graph-container .background.blue { +.background.blue { background: #5e64ff; } -.graph-container .background.red { +.background.red { background: #ff5858; } -.graph-container .background.light-green { +.background.light-green { background: #98d85b; } -.graph-container .background.green { +.background.green { background: #28a745; } -.graph-container .background.orange { +.background.orange { background: #ffa00a; } -.graph-container .background.purple { +.background.purple { background: #743ee2; } -.graph-container .background.darkgrey { +.background.darkgrey { background: #b8c2cc; } -.graph-container .background.black { +.background.black { background: #36414C; } -.graph-container .background.yellow { +.background.yellow { background: #FEEF72; } -.graph-container .background.light-blue { +.background.light-blue { background: #7CD6FD; } -.graph-container .background.lightblue { +.background.lightblue { background: #7CD6FD; } -.graph-container .border-top.grey { +.border-top.grey { border-top: 3px solid #F0F4F7; } -.graph-container .border-top.blue { +.border-top.blue { border-top: 3px solid #5e64ff; } -.graph-container .border-top.red { +.border-top.red { border-top: 3px solid #ff5858; } -.graph-container .border-top.light-green { +.border-top.light-green { border-top: 3px solid #98d85b; } -.graph-container .border-top.green { +.border-top.green { border-top: 3px solid #28a745; } -.graph-container .border-top.orange { +.border-top.orange { border-top: 3px solid #ffa00a; } -.graph-container .border-top.purple { +.border-top.purple { border-top: 3px solid #743ee2; } -.graph-container .border-top.darkgrey { +.border-top.darkgrey { border-top: 3px solid #b8c2cc; } -.graph-container .border-top.black { +.border-top.black { border-top: 3px solid #36414C; } -.graph-container .border-top.yellow { +.border-top.yellow { border-top: 3px solid #FEEF72; } -.graph-container .border-top.light-blue { +.border-top.light-blue { border-top: 3px solid #7CD6FD; } -.graph-container .border-top.lightblue { +.border-top.lightblue { border-top: 3px solid #7CD6FD; } diff --git a/frappe/public/js/frappe/ui/graphs.js b/frappe/public/js/frappe/ui/graphs.js index 3c90ec411c..4807835e85 100644 --- a/frappe/public/js/frappe/ui/graphs.js +++ b/frappe/public/js/frappe/ui/graphs.js @@ -28,7 +28,6 @@ frappe.ui.Graph = class Graph { specific_values = [], summary = [], - color = 'blue', mode = '', }) { @@ -58,12 +57,14 @@ frappe.ui.Graph = class Graph { this.specific_values = specific_values; this.summary = summary; - this.color = color; this.mode = mode; + this.current_hover_index = 0; + this.current_selected_index = 0; + this.$graph = null; - // Validate all arguments + // Validate all arguments, check passed data format, set defaults frappe.require("assets/frappe/js/lib/snap.svg-min.js", this.setup.bind(this)); } @@ -81,18 +82,15 @@ frappe.ui.Graph = class Graph { refresh() { - this.base_width = this.parent.width() - 20; - this.width = this.base_width - 100; + this.base_width = this.parent.width(); + this.width = this.base_width - this.translate_x * 2; this.setup_container(); - + this.setup_components(); this.setup_values(); - this.setup_utils(); - this.setup_components(); this.make_graph_components(); - this.make_tooltip(); if(this.summary.length > 0) { @@ -110,11 +108,11 @@ frappe.ui.Graph = class Graph { .addClass('graph-container') .append($(`
${this.title}
`)) .append($(`
${this.subtitle}
`)) - .append($(`
`)) + .append($(`
`)) .append($(`
`)) .appendTo(this.parent); - this.$graphics = this.container.find('.graph-graphics'); + this.$graphics = this.container.find('.graphics'); this.$stats_container = this.container.find('.graph-stats-container'); this.$graph = $('
') @@ -173,17 +171,19 @@ frappe.ui.Graph = class Graph { setup_components() { this.y_axis_group = this.snap.g().attr({ class: "y axis" }); this.x_axis_group = this.snap.g().attr({ class: "x axis" }); - this.data_units = this.snap.g().attr({ class: "graph-data-points" }); + this.data_units = this.snap.g().attr({ class: "data-points" }); this.specific_y_lines = this.snap.g().attr({ class: "specific axis" }); } make_graph_components() { this.make_y_axis(); this.make_x_axis(); + this.y_colors = ['lightblue', 'purple', 'blue', 'green', 'lightgreen', + 'yellow', 'orange', 'red'] this.y.map((d, i) => { - this.make_units(d.y_tops, d.color, i); - this.make_path(d); + this.make_units(d.y_tops, d.color || this.y_colors[i], i); + this.make_path(d, d.color || this.y_colors[i]); }); if(this.specific_values.length > 0) { @@ -272,75 +272,58 @@ frappe.ui.Graph = class Graph { make_path() { } make_tooltip() { - this.tip = $(`
- -
    -
-
`).attr({ - style: `top: 0px; left: 0px; opacity: 0; pointer-events: none;` - }).appendTo(this.$graphics); - - this.tip_title = this.tip.find('.title'); - this.tip_data_point_list = this.tip.find('.data-point-list'); - + // should be w.r.t. this.parent + this.tip = new frappe.ui.SvgTip({ + parent: this.$graphics, + }); this.bind_tooltip(); } bind_tooltip() { + // should be w.r.t. this.parent, but will have to take care of + // all the elements and padding, margins on top this.$graphics.on('mousemove', (e) => { - let offset = $(this.$graphics).offset(); + let offset = this.$graphics.offset(); var relX = e.pageX - offset.left - this.translate_x; var relY = e.pageY - offset.top - this.translate_y; - if(relY < this.height) { - for(var i=this.x_axis_values.length - 1; i >= 0 ; i--) { - let x_val = this.x_axis_values[i]; - if(relX > x_val - this.avg_unit_width/2) { - let x = x_val - this.tip.width()/2 + this.translate_x; - let y = this.y_min_tops[i] - this.tip.height() + this.translate_y; - - this.fill_tooltip(i); - - this.tip.attr({ - style: `top: ${y}px; left: ${x-0.5}px; opacity: 1; pointer-events: none;` - }); - break; - } - } + if(relY < this.height + this.translate_y * 2) { + this.map_tooltip_x_position_and_show(relX); } else { - this.tip.attr({ - style: `top: 0px; left: 0px; opacity: 0; pointer-events: none;` - }); + this.tip.hide_tip() } }); - - this.$graphics.on('mouseleave', () => { - this.tip.attr({ - style: `top: 0px; left: 0px; opacity: 0; pointer-events: none;` - }); - }); } - fill_tooltip(i) { - this.tip_title.html(this.x.formatted && this.x.formatted.length>0 - ? this.x.formatted[i] : this.x.values[i]); - this.tip_data_point_list.empty(); - this.y.map(y_set => { - let $li = $(`
  • - - ${y_set.formatted ? y_set.formatted[i] : y_set.values[i]} - - ${y_set.title ? y_set.title : '' } -
  • `).addClass(`border-top ${y_set.color}`); - this.tip_data_point_list.append($li); - }); + map_tooltip_x_position_and_show(relX) { + for(var i=this.x_axis_values.length - 1; i >= 0 ; i--) { + let x_val = this.x_axis_values[i]; + // let delta = i === 0 ? this.avg_unit_width : x_val - this.x_axis_values[i-1]; + if(relX > x_val - this.avg_unit_width/2) { + let x = x_val + this.translate_x - 0.5; + let y = this.y_min_tops[i] + this.translate_y; + let title = this.x.formatted && this.x.formatted.length>0 + ? this.x.formatted[i] : this.x.values[i]; + let values = this.y.map((set, j) => { + return { + title: set.title, + value: set.formatted ? set.formatted[i] : set.values[i], + color: set.color || this.y_colors[j], + } + }); + + this.tip.set_values(x, y, title, '', values); + this.tip.show_tip(); + break; + } + } } show_specific_values() { this.specific_values.map(d => { this.specific_y_lines.add(this.snap.g( this.snap.line(0, 0, this.width, 0).attr({ - class: d.line_type === "dashed" ? "graph-dashed": "" + class: d.line_type === "dashed" ? "dashed": "" }), this.snap.text(this.width + 5, 0, d.name.toUpperCase()).attr({ dy: ".32em", @@ -498,10 +481,10 @@ frappe.ui.LineGraph = class LineGraph extends frappe.ui.Graph { }; } - make_path(d) { + make_path(d, color) { let points_list = d.y_tops.map((y, i) => (this.x_axis_values[i] + ',' + y)); let path_str = "M"+points_list.join("L"); - d.path = this.snap.path(path_str).attr({class: `stroke ${d.color}`}); + d.path = this.snap.path(path_str).attr({class: `stroke ${color}`}); this.data_units.prepend(d.path); } }; @@ -512,7 +495,9 @@ frappe.ui.PercentageGraph = class PercentageGraph extends frappe.ui.Graph { } make_graph_area() { - this.$graphics.addClass('graph-focus-margin'); + this.$graphics.addClass('graph-focus-margin').attr({ + style: `margin-top: 45px;` + }); this.$stats_container.addClass('graph-focus-margin').attr({ style: `padding-top: 0px; margin-bottom: 30px;` }); @@ -537,28 +522,41 @@ frappe.ui.PercentageGraph = class PercentageGraph extends frappe.ui.Graph { this.x.colors = ['green', 'blue', 'purple', 'red', 'orange', 'yellow', 'lightblue', 'lightgreen']; } - - // Calculate x unit distances for tooltips } setup_utils() { } setup_components() { this.$percentage_bar = $(`
    -
    `).appendTo(this.$chart); +
    `).appendTo(this.$chart); // get this.height, width and avg from this if needed } make_graph_components() { - let grand_total = this.x.totals.reduce((a, b) => a + b, 0); + this.grand_total = this.x.totals.reduce((a, b) => a + b, 0); this.x.units = []; this.x.totals.map((total, i) => { let $part = $(`
    `); + style="width: ${total*100/this.grand_total}%">`); this.x.units.push($part); this.$percentage_bar.append($part); }); } - make_tooltip() { } + bind_tooltip() { + this.x.units.map(($part, i) => { + $part.on('mouseenter', () => { + let g_off = this.$graphics.offset(), p_off = $part.offset(); + + let x = p_off.left - g_off.left + $part.width()/2; + let y = p_off.top - g_off.top - 6; + let title = (this.x.formatted && this.x.formatted.length>0 + ? this.x.formatted[i] : this.x.values[i]) + ': '; + let percent = (this.x.totals[i]*100/this.grand_total).toFixed(1); + + this.tip.set_values(x, y, title, percent); + this.tip.show_tip(); + }); + }); + } show_summary() { let x_values = this.x.formatted && this.x.formatted.length > 0 @@ -576,8 +574,157 @@ frappe.ui.PercentageGraph = class PercentageGraph extends frappe.ui.Graph { } }; +frappe.ui.CompositeGraph = class { + constructor({ + parent = null + }) { + this.parent = parent; + this.title_name = ''; + this.title_value = ''; + this.list_values = []; + + this.x = 0; + this.y = 0; + + this.top = 0; + this.left = 0; + + this.setup(); + } + + +} + +frappe.ui.Heatmap = class { + constructor({ + parent = null + }) { + this.parent = parent; + this.title_name = ''; + this.title_value = ''; + this.list_values = []; + + this.x = 0; + this.y = 0; + + this.top = 0; + this.left = 0; + + this.setup(); + } + + +} + +frappe.ui.SvgTip = class { + constructor({ + parent = null + }) { + this.parent = parent; + this.title_name = ''; + this.title_value = ''; + this.list_values = []; + + this.x = 0; + this.y = 0; + + this.top = 0; + this.left = 0; + + this.setup(); + } + + setup() { + this.make_tooltip(); + } + + refresh() { + this.fill(); + this.calc_position(); + // this.show_tip(); + } + + make_tooltip() { + this.container = $(`
    + +
      +
      +
      `).appendTo(this.parent); + this.hide_tip(); + + this.title = this.container.find('.title'); + this.data_point_list = this.container.find('.data-point-list'); + + this.parent.on('mouseleave', () => { + this.hide_tip(); + }); + } + + fill() { + this.title.html(`${this.title_name}${this.title_value}`); + this.data_point_list.empty(); + this.list_values.map((set, i) => { + let $li = $(`
    • + ${set.value ? set.value : '' } + ${set.title ? set.title : '' } +
    • `).addClass(`border-top ${set.color || 'black'}`); + + this.data_point_list.append($li); + }); + } + + calc_position() { + this.top = this.y - this.container.height(); + this.left = this.x - this.container.width()/2; + let max_left = this.parent.width() - this.container.width(); + + let $pointer = this.container.find('.svg-pointer'); + + if(this.left < 0) { + $pointer.css({ 'left': `calc(50% - ${-1 * this.left}px)` }); + this.left = 0; + } else if(this.left > max_left) { + let delta = this.left - max_left; + $pointer.css({ 'left': `calc(50% + ${delta}px)` }); + this.left = max_left; + } else { + $pointer.css({ 'left': `50%` }); + } + } + + set_values(x, y, title_name = '', title_value = '', list_values = []) { + this.title_name = title_name; + this.title_value = title_value; + this.list_values = list_values; + this.x = x; + this.y = y; + this.refresh(); + } + + hide_tip() { + this.container.css({ + 'top': '0px', + 'left': '0px', + 'opacity': '0' + }); + } + + show_tip() { + this.container.css({ + 'top': this.top + 'px', + 'left': this.left + 'px', + 'opacity': '1' + }); + } +}; + + frappe.provide("frappe.ui.graphs"); +frappe.ui.graphs.get_timeseries = function(start, frequency, length) { + +} + frappe.ui.graphs.map_c3 = function(chart) { if (chart.data) { let data = chart.data; @@ -606,7 +753,6 @@ frappe.ui.graphs.map_c3 = function(chart) { y.push({ title: title, values: col, - color: 'blue' }); } else { dirty = true; @@ -638,7 +784,6 @@ frappe.ui.graphs.map_c3 = function(chart) { y.push({ title: 'data' + i, values: row, - color: 'blue' }) } }); diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index 7cac0244ef..9ac0fd056e 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -939,9 +939,13 @@ frappe.views.QueryReport = Class.extend({ return; } + console.log(chart); + let args = frappe.ui.graphs.map_c3(chart); this.chart_area.empty().toggle(true); + console.log(args); + if(args) { $.extend(args, { parent: this.chart_area, diff --git a/frappe/public/less/graphs.less b/frappe/public/less/graphs.less index 92f8c2b1e1..fae703e53a 100644 --- a/frappe/public/less/graphs.less +++ b/frappe/public/less/graphs.less @@ -5,9 +5,10 @@ margin: 0px 5%; } - .graph-graphics { + .graphics { margin-top: 10px; - padding: 10px 0px; + padding-top: 10px; + padding-bottom: 10px; position: relative; } @@ -57,14 +58,12 @@ } .percentage-graph { - margin-top: 35px; - .progress { margin-bottom: 0px; } } - .graph-data-points { + .data-points { circle { // fill: #28a745; stroke: #fff; @@ -83,7 +82,7 @@ } } - line.graph-dashed { + line.dashed { stroke-dasharray: 5,3; } @@ -104,216 +103,215 @@ text-anchor: middle; } } +} - .graph-svg-tip { - position: absolute; - z-index: 99999; - padding: 10px; - font-size: 12px; - color: #959da5; - text-align: center; - background: rgba(0,0,0,0.8); - border-radius: 3px; - - &.comparison { - padding: 0; - text-align: left; +.graph-svg-tip { + position: absolute; + z-index: 99999; + padding: 10px; + font-size: 12px; + color: #959da5; + text-align: center; + background: rgba(0,0,0,0.8); + border-radius: 3px; + + &.comparison { + padding: 0; + text-align: left; + pointer-events: none; + + .title { + display: block; + padding: 10px; + margin: 0; + font-weight: 600; + line-height: 1; pointer-events: none; - - .title { - display: block; - padding: 10px; - margin: 0; - font-weight: 600; - line-height: 1; - pointer-events: none; - } - - ul { - margin: 0; - white-space: nowrap; - list-style: none; - } - - li { - display: inline-block; - padding: 5px 10px; - } } - ul, ol { - padding-left: 0; - display: flex; + ul { + margin: 0; + white-space: nowrap; + list-style: none; } - ul.data-point-list li { - min-width: 90px; - flex: 1; - } - - strong { - color: #dfe2e5; - } - - &::after { - position: absolute; - bottom: -10px; - left: 50%; - width: 5px; - height: 5px; - margin: 0 0 0 -5px; - content: " "; - border: 5px solid transparent; - border-top-color: rgba(0,0,0,0.8); + li { + display: inline-block; + padding: 5px 10px; } } - .stroke.grey { - stroke: #F0F4F7; - } - .stroke.blue { - stroke: #5e64ff; - } - .stroke.red { - stroke: #ff5858; - } - .stroke.light-green { - stroke: #98d85b; - } - .stroke.green { - stroke: #28a745; - } - .stroke.orange { - stroke: #ffa00a; - } - .stroke.purple { - stroke: #743ee2; - } - .stroke.darkgrey { - stroke: #b8c2cc; - } - .stroke.black { - stroke: #36414C; - } - .stroke.yellow { - stroke: #FEEF72; - } - .stroke.light-blue { - stroke: #7CD6FD; - } - .stroke.lightblue { - stroke: #7CD6FD; + ul, ol { + padding-left: 0; + display: flex; } - .fill.grey { - fill: #F0F4F7; - } - .fill.blue { - fill: #5e64ff; - } - .fill.red { - fill: #ff5858; - } - .fill.light-green { - fill: #98d85b; - } - .fill.green { - fill: #28a745; - } - .fill.orange { - fill: #ffa00a; - } - .fill.purple { - fill: #743ee2; - } - .fill.darkgrey { - fill: #b8c2cc; - } - .fill.black { - fill: #36414C; - } - .fill.yellow { - fill: #FEEF72; - } - .fill.light-blue { - fill: #7CD6FD; - } - .fill.lightblue { - fill: #7CD6FD; + ul.data-point-list li { + min-width: 90px; + flex: 1; } - .background.grey { - background: #F0F4F7; - } - .background.blue { - background: #5e64ff; - } - .background.red { - background: #ff5858; - } - .background.light-green { - background: #98d85b; - } - .background.green { - background: #28a745; - } - .background.orange { - background: #ffa00a; - } - .background.purple { - background: #743ee2; - } - .background.darkgrey { - background: #b8c2cc; - } - .background.black { - background: #36414C; - } - .background.yellow { - background: #FEEF72; - } - .background.light-blue { - background: #7CD6FD; - } - .background.lightblue { - background: #7CD6FD; + strong { + color: #dfe2e5; } - .border-top.grey { - border-top: 3px solid #F0F4F7; - } - .border-top.blue { - border-top: 3px solid #5e64ff; - } - .border-top.red { - border-top: 3px solid #ff5858; - } - .border-top.light-green { - border-top: 3px solid #98d85b; - } - .border-top.green { - border-top: 3px solid #28a745; - } - .border-top.orange { - border-top: 3px solid #ffa00a; - } - .border-top.purple { - border-top: 3px solid #743ee2; - } - .border-top.darkgrey { - border-top: 3px solid #b8c2cc; - } - .border-top.black { - border-top: 3px solid #36414C; - } - .border-top.yellow { - border-top: 3px solid #FEEF72; - } - .border-top.light-blue { - border-top: 3px solid #7CD6FD; - } - .border-top.lightblue { - border-top: 3px solid #7CD6FD; + .svg-pointer { + position: absolute; + bottom: -10px; + left: 50%; + width: 5px; + height: 5px; + margin: 0 0 0 -5px; + content: " "; + border: 5px solid transparent; + border-top-color: rgba(0,0,0,0.8); } +} +.stroke.grey { + stroke: #F0F4F7; +} +.stroke.blue { + stroke: #5e64ff; +} +.stroke.red { + stroke: #ff5858; +} +.stroke.light-green { + stroke: #98d85b; +} +.stroke.green { + stroke: #28a745; +} +.stroke.orange { + stroke: #ffa00a; +} +.stroke.purple { + stroke: #743ee2; +} +.stroke.darkgrey { + stroke: #b8c2cc; +} +.stroke.black { + stroke: #36414C; +} +.stroke.yellow { + stroke: #FEEF72; +} +.stroke.light-blue { + stroke: #7CD6FD; +} +.stroke.lightblue { + stroke: #7CD6FD; +} + +.fill.grey { + fill: #F0F4F7; +} +.fill.blue { + fill: #5e64ff; +} +.fill.red { + fill: #ff5858; +} +.fill.light-green { + fill: #98d85b; +} +.fill.green { + fill: #28a745; +} +.fill.orange { + fill: #ffa00a; +} +.fill.purple { + fill: #743ee2; +} +.fill.darkgrey { + fill: #b8c2cc; +} +.fill.black { + fill: #36414C; +} +.fill.yellow { + fill: #FEEF72; +} +.fill.light-blue { + fill: #7CD6FD; +} +.fill.lightblue { + fill: #7CD6FD; +} + +.background.grey { + background: #F0F4F7; +} +.background.blue { + background: #5e64ff; +} +.background.red { + background: #ff5858; +} +.background.light-green { + background: #98d85b; +} +.background.green { + background: #28a745; +} +.background.orange { + background: #ffa00a; +} +.background.purple { + background: #743ee2; +} +.background.darkgrey { + background: #b8c2cc; +} +.background.black { + background: #36414C; +} +.background.yellow { + background: #FEEF72; +} +.background.light-blue { + background: #7CD6FD; +} +.background.lightblue { + background: #7CD6FD; +} + +.border-top.grey { + border-top: 3px solid #F0F4F7; +} +.border-top.blue { + border-top: 3px solid #5e64ff; +} +.border-top.red { + border-top: 3px solid #ff5858; +} +.border-top.light-green { + border-top: 3px solid #98d85b; +} +.border-top.green { + border-top: 3px solid #28a745; +} +.border-top.orange { + border-top: 3px solid #ffa00a; +} +.border-top.purple { + border-top: 3px solid #743ee2; +} +.border-top.darkgrey { + border-top: 3px solid #b8c2cc; +} +.border-top.black { + border-top: 3px solid #36414C; +} +.border-top.yellow { + border-top: 3px solid #FEEF72; +} +.border-top.light-blue { + border-top: 3px solid #7CD6FD; +} +.border-top.lightblue { + border-top: 3px solid #7CD6FD; } From 63824d0012cddf9b7b4179aaa8c4c8deeaac2b5a Mon Sep 17 00:00:00 2001 From: pratu16x7 Date: Mon, 11 Sep 2017 08:51:47 +0530 Subject: [PATCH 3/5] Heatmap for month domain --- frappe/public/css/graphs.css | 10 +- frappe/public/js/frappe/form/dashboard.js | 21 +- frappe/public/js/frappe/ui/graphs.js | 355 +++++++++++++++--- .../js/frappe/views/reports/query_report.js | 22 +- frappe/public/less/graphs.less | 14 +- 5 files changed, 334 insertions(+), 88 deletions(-) diff --git a/frappe/public/css/graphs.css b/frappe/public/css/graphs.css index 3d3f269b0c..e0f62b3cd9 100644 --- a/frappe/public/css/graphs.css +++ b/frappe/public/css/graphs.css @@ -35,13 +35,13 @@ .graph-container .graph-stats-container .graph-data .stats-value { color: #98d85b; } -.graph-container .bar-graph .axis, -.graph-container .line-graph .axis { +.graph-container .axis, +.graph-container .chart-label { font-size: 10px; - fill: #6a737d; + fill: #959ba1; } -.graph-container .bar-graph .axis line, -.graph-container .line-graph .axis line { +.graph-container .axis line, +.graph-container .chart-label line { stroke: rgba(27, 31, 35, 0.1); } .graph-container .percentage-graph .progress { diff --git a/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js index 38ee1bbb70..c7ce98e537 100644 --- a/frappe/public/js/frappe/form/dashboard.js +++ b/frappe/public/js/frappe/form/dashboard.js @@ -334,22 +334,11 @@ frappe.ui.form.Dashboard = Class.extend({ // heatmap render_heatmap: function() { if(!this.heatmap) { - this.heatmap = new CalHeatMap(); - this.heatmap.init({ - itemSelector: "#heatmap-" + frappe.model.scrub(this.frm.doctype), - domain: "month", - subDomain: "day", - start: moment().subtract(1, 'year').add(1, 'month').toDate(), - cellSize: 9, - cellPadding: 2, - domainGutter: 2, - range: 12, - domainLabelFormat: function(date) { - return moment(date).format("MMM").toUpperCase(); - }, - displayLegend: false, - legend: [5, 10, 15, 20] - // subDomainTextFormat: "%d", + this.heatmap = new frappe.ui.HeatMap({ + parent: this.heatmap_area.find("#heatmap-" + frappe.model.scrub(this.frm.doctype)), + height: 100, + start: new Date(moment().subtract(1, 'year').toDate()), + discrete_domains: 1 }); // center the heatmap diff --git a/frappe/public/js/frappe/ui/graphs.js b/frappe/public/js/frappe/ui/graphs.js index 4807835e85..041928cebe 100644 --- a/frappe/public/js/frappe/ui/graphs.js +++ b/frappe/public/js/frappe/ui/graphs.js @@ -42,15 +42,14 @@ frappe.ui.Graph = class Graph { } this.parent = parent; - this.base_height = height; - this.height = height - 40; - this.translate_x = 60; - this.translate_y = 10; + this.set_margins(height); this.title = title; this.subtitle = subtitle; + // Begin axis graph-related args + this.y = y; this.x = x; @@ -59,8 +58,8 @@ frappe.ui.Graph = class Graph { this.mode = mode; - this.current_hover_index = 0; - this.current_selected_index = 0; + // this.current_hover_index = 0; + // this.current_selected_index = 0; this.$graph = null; @@ -82,7 +81,8 @@ frappe.ui.Graph = class Graph { refresh() { - this.base_width = this.parent.width(); + this.setup_base_values(); + this.set_width(); this.width = this.base_width - this.translate_x * 2; this.setup_container(); @@ -100,6 +100,20 @@ frappe.ui.Graph = class Graph { } } + set_margins(height) { + this.base_height = height; + this.height = height - 40; + + this.translate_x = 60; + this.translate_y = 10; + } + + set_width() { + this.base_width = this.parent.width(); + } + + setup_base_values() {} + setup_container() { // Graph needs a dedicated parent element this.parent.empty(); @@ -128,6 +142,13 @@ frappe.ui.Graph = class Graph { return this.$svg; } + setup_components() { + this.y_axis_group = this.snap.g().attr({ class: "y axis" }); + this.x_axis_group = this.snap.g().attr({ class: "x axis" }); + this.data_units = this.snap.g().attr({ class: "data-points" }); + this.specific_y_lines = this.snap.g().attr({ class: "specific axis" }); + } + setup_values() { // Multiplier let all_values = this.specific_values.map(d => d.value); @@ -168,13 +189,6 @@ frappe.ui.Graph = class Graph { }); } - setup_components() { - this.y_axis_group = this.snap.g().attr({ class: "y axis" }); - this.x_axis_group = this.snap.g().attr({ class: "x axis" }); - this.data_units = this.snap.g().attr({ class: "data-points" }); - this.specific_y_lines = this.snap.g().attr({ class: "specific axis" }); - } - make_graph_components() { this.make_y_axis(); this.make_x_axis(); @@ -246,6 +260,11 @@ frappe.ui.Graph = class Graph { transform: `translate(0,${start_at})` }); this.x.values.map((point, i) => { + let allowed_space = this.avg_unit_width * 1.5; + if(this.get_strwidth(point) > allowed_space) { + let allowed_letters = allowed_space / 8; + point = point.slice(0, allowed_letters-3) + " ..."; + } this.x_axis_group.add(this.snap.g( this.snap.line(0, 0, 0, height), this.snap.text(0, text_start_at, point).attr({ @@ -262,8 +281,13 @@ frappe.ui.Graph = class Graph { make_units(y_values, color, dataset_index) { let d = this.unit_args; y_values.map((y, i) => { - let data_unit = this.draw[d.type](this.x_axis_values[i], - y, d.args, color, dataset_index); + let data_unit = this.draw[d.type]( + this.x_axis_values[i], + y, + d.args, + color, + dataset_index + ); this.data_units.add(data_unit); this.y[dataset_index].data_units.push(data_unit); }); @@ -417,6 +441,9 @@ frappe.ui.Graph = class Graph { let width = total_width / args.no_of_datasets; let current_x = start_x + width * index; + if(y == this.height) { + y = this.height * 0.98; + } return this.snap.rect(current_x, y, width, this.height - y).attr({ class: `bar mini fill ${color}` }); @@ -425,6 +452,11 @@ frappe.ui.Graph = class Graph { return this.snap.circle(x, y, args.radius).attr({ class: `fill ${color}` }); + }, + 'rect': (x, y, args, color) => { + return this.snap.rect(x, y, args.width, args.height).attr({ + class: `fill ${color}` + }); } }; @@ -453,6 +485,7 @@ frappe.ui.BarGraph = class BarGraph extends frappe.ui.Graph { this.unit_args = { type: 'bar', args: { + // More intelligent width setting space_width: this.y.length > 1 ? me.avg_unit_width/2 : me.avg_unit_width/8, no_of_datasets: this.y.length @@ -574,46 +607,252 @@ frappe.ui.PercentageGraph = class PercentageGraph extends frappe.ui.Graph { } }; -frappe.ui.CompositeGraph = class { +frappe.ui.HeatMap = class HeatMap extends frappe.ui.Graph { constructor({ - parent = null - }) { - this.parent = parent; - this.title_name = ''; - this.title_value = ''; - this.list_values = []; + parent = null, + height = 240, + title = '', subtitle = '', - this.x = 0; - this.y = 0; + start = '', + domain = '', + subdomain = '', + data = {}, + discrete_domains = 0, - this.top = 0; - this.left = 0; + // remove these graph related args + y = [], + x = [], + specific_values = [], + summary = [], + mode = 'heatmap', + } = {}) { + super(arguments[0]); + this.start = start; + this.data = data; + this.discrete_domains = discrete_domains; + + this.legend_colors = ['#ebedf0', '#c6e48b', '#7bc96f', '#239a3b', '#196127']; + } - this.setup(); + setup_base_values() { + this.today = new Date(); + + this.first_week_start = new Date(this.start.toDateString()); + this.last_week_start = new Date(this.today.toDateString()); + if(this.first_week_start.getDay() !== 7) { + this.add_days(this.first_week_start, (-1) * this.first_week_start.getDay()); + } + if(this.last_week_start.getDay() !== 7) { + this.add_days(this.last_week_start, (-1) * this.last_week_start.getDay()); + } + this.no_of_cols = this.get_weeks_between(this.first_week_start + '', this.last_week_start + '') + 1; } + set_width() { + this.base_width = (this.no_of_cols + 14) * 12; + } -} + setup_components() { + this.domain_label_group = this.snap.g().attr({ class: "domain-label-group chart-label" }); + this.data_groups = this.snap.g().attr({ class: "data-groups", transform: `translate(0, 20)` }); + } -frappe.ui.Heatmap = class { - constructor({ - parent = null - }) { - this.parent = parent; - this.title_name = ''; - this.title_value = ''; - this.list_values = []; + setup_values() { + this.distribution = this.get_distribution(this.data, this.legend_colors); + this.month_names = ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" + ]; - this.x = 0; - this.y = 0; + this.render_all_weeks_and_store_x_values(this.no_of_cols); + } - this.top = 0; - this.left = 0; + render_all_weeks_and_store_x_values(no_of_weeks) { + let current_week_sunday = new Date(this.first_week_start); + this.week_col = 0; + this.current_month = current_week_sunday.getMonth(); + + this.months = [this.current_month + '']; + this.month_weeks = {}, this.month_start_points = []; + this.month_weeks[this.current_month] = 0; + this.month_start_points.push(13); + + for(var i = 0; i < no_of_weeks; i++) { + let data_group, month_change = 0; + let day = new Date(current_week_sunday); + + [data_group, month_change] = this.get_week_squares_group(day, this.week_col); + this.data_groups.add(data_group); + this.week_col += 1 + parseInt(this.discrete_domains && month_change); + this.month_weeks[this.current_month]++; + if(month_change) { + this.current_month = (this.current_month + 1) % 12; + this.months.push(this.current_month + ''); + this.month_weeks[this.current_month] = 1; + } + this.add_days(current_week_sunday, 7); + } + this.render_month_labels(); + } - this.setup(); + get_week_squares_group(current_date, index) { + const no_of_weekdays = 7; + const square_side = 10; + const cell_padding = 2; + const step = 1; + + let month_change = 0; + let week_col_change = 0; + + let data_group = this.snap.g().attr({ class: "data-group" }); + + for(var y = 0, i = 0; i < no_of_weekdays; i += step, y += (square_side + cell_padding)) { + let data_value = 0; + let color_index = 0; + + // TODO: More foolproof for any data + let timestamp = Math.floor(current_date.getTime()/1000).toFixed(1); + + if(this.data[timestamp]) { + data_value = this.data[timestamp]; + color_index = this.get_max_checkpoint(data_value, this.distribution); + } + + let x = 13 + (index + week_col_change) * 12; + + data_group.add(this.snap.rect(x, y, square_side, square_side).attr({ + 'class': `day`, + 'fill': this.legend_colors[color_index], + 'data-date': this.get_dd_mm_yyyy(current_date), + 'data-value': data_value, + 'data-day': current_date.getDay() + })); + + let next_date = new Date(current_date); + this.add_days(next_date, 1); + if(next_date.getMonth() - current_date.getMonth()) { + month_change = 1; + if(this.discrete_domains) { + week_col_change = 1; + } + + this.month_start_points.push(13 + (index + week_col_change) * 12); + } + current_date = next_date; + } + + return [data_group, month_change]; + } + + render_month_labels() { + this.first_month_label = 1; + if (this.first_week_start.getDate() > 8) { + this.first_month_label = 0; + } + this.last_month_label = 1; + + let first_month = this.months.shift(); + let first_month_start = this.month_start_points.shift(); + // render first month if + + let last_month = this.months.pop(); + let last_month_start = this.month_start_points.pop(); + // render last month if + + this.month_start_points.map((start, i) => { + let month_name = this.month_names[this.months[i]].substring(0, 3); + this.domain_label_group.add(this.snap.text(start + 12, 10, month_name).attr({ + dy: ".32em", + class: "y-value-text" + })); + }); } + make_graph_components() { + this.container.find('.graph-stats-container, .sub-title, .title').hide(); + this.container.find('.graphics').css({'margin-top': '0px', 'padding-top': '0px'}); + } + + bind_tooltip() { + this.container.on('mouseenter', '.day', (e) => { + let subdomain = $(e.target); + let count = subdomain.attr('data-value'); + let date_parts = subdomain.attr('data-date').split('-'); + + let month = this.month_names[parseInt(date_parts[1])-1].substring(0, 3); + let g_off = this.$graphics.offset(), p_off = subdomain.offset(); + + let width = parseInt(subdomain.attr('width')); + let x = p_off.left - g_off.left + (width+2)/2; + let y = p_off.top - g_off.top - (width+2)/2; + let value = count + ' items'; + let name = ' on ' + month + ' ' + date_parts[0] + ', ' + date_parts[2]; + + this.tip.set_values(x, y, name, value, [], 1); + this.tip.show_tip(); + }); + } + + update(data) { + this.data = data; + this.setup_values(); + } + + get_distribution(data, mapper_array) { + let data_values = Object.keys(data).map(key => data[key]); + let data_max_value = Math.max(...data_values); + + let distribution_step = 1 / (mapper_array.length - 1); + let distribution = []; + + mapper_array.map((color, i) => { + let checkpoint = data_max_value * (distribution_step * i); + distribution.push(checkpoint); + }); + + return distribution; + } + + get_max_checkpoint(value, distribution) { + return distribution.filter(d => { + return d <= value; + }).length - 1; + } + + // TODO: date utils, move these out + + // https://stackoverflow.com/a/11252167/6495043 + treat_as_utc(date_str) { + let result = new Date(date_str); + result.setMinutes(result.getMinutes() - result.getTimezoneOffset()); + return result; + } + + get_dd_mm_yyyy(date) { + let dd = date.getDate(); + let mm = date.getMonth() + 1; // getMonth() is zero-based + return [ + (dd>9 ? '' : '0') + dd, + (mm>9 ? '' : '0') + mm, + date.getFullYear() + ].join('-'); + } + + get_weeks_between(start_date_str, end_date_str) { + return Math.ceil(this.get_days_between(start_date_str, end_date_str) / 7); + } + + get_days_between(start_date_str, end_date_str) { + let milliseconds_per_day = 24 * 60 * 60 * 1000; + return (this.treat_as_utc(end_date_str) - this.treat_as_utc(start_date_str)) / milliseconds_per_day; + } + + // mutates + add_days(date, number_of_days) { + date.setDate(date.getDate() + number_of_days); + } + + get_month_name() {} } frappe.ui.SvgTip = class { @@ -624,6 +863,7 @@ frappe.ui.SvgTip = class { this.title_name = ''; this.title_value = ''; this.list_values = []; + this.title_value_first = 0; this.x = 0; this.y = 0; @@ -661,7 +901,13 @@ frappe.ui.SvgTip = class { } fill() { - this.title.html(`${this.title_name}${this.title_value}`); + let title; + if(this.title_value_first) { + title = `${this.title_value}${this.title_name}`; + } else { + title = `${this.title_name}${this.title_value}`; + } + this.title.html(title); this.data_point_list.empty(); this.list_values.map((set, i) => { let $li = $(`
    • @@ -692,12 +938,13 @@ frappe.ui.SvgTip = class { } } - set_values(x, y, title_name = '', title_value = '', list_values = []) { + set_values(x, y, title_name = '', title_value = '', list_values = [], title_value_first = 0) { this.title_name = title_name; this.title_value = title_value; this.list_values = list_values; this.x = x; this.y = y; + this.title_value_first = title_value_first; this.refresh(); } @@ -796,3 +1043,23 @@ frappe.ui.graphs.map_c3 = function(chart) { } } } + + +// frappe.ui.CompositeGraph = class { +// constructor({ +// parent = null +// }) { +// this.parent = parent; +// this.title_name = ''; +// this.title_value = ''; +// this.list_values = []; + +// this.x = 0; +// this.y = 0; + +// this.top = 0; +// this.left = 0; + +// this.setup(); +// } +// } diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index 9ac0fd056e..8b1a801099 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -929,28 +929,22 @@ frappe.views.QueryReport = Class.extend({ setup_chart: function(res) { this.chart_area.toggle(false); - let chart, g; if (this.get_query_report_opts().get_chart_data) { - chart = this.get_query_report_opts().get_chart_data(res.columns, res.result); + var opts = this.get_query_report_opts().get_chart_data(res.columns, res.result); } else if (res.chart) { - chart = res.chart; + var opts = res.chart; } else { return; } - console.log(chart); - - let args = frappe.ui.graphs.map_c3(chart); - this.chart_area.empty().toggle(true); - - console.log(args); + $.extend(opts, { + wrapper: this.chart_area, + }); - if(args) { - $.extend(args, { - parent: this.chart_area, - }); - g = new frappe.ui.Graph(args); + this.chart = new frappe.ui.Chart(opts); + if(this.chart && opts.data && opts.data.rows && opts.data.rows.length) { + this.chart_area.toggle(true); } }, diff --git a/frappe/public/less/graphs.less b/frappe/public/less/graphs.less index fae703e53a..079fbf17dc 100644 --- a/frappe/public/less/graphs.less +++ b/frappe/public/less/graphs.less @@ -44,16 +44,12 @@ } } - .bar-graph, .line-graph { + .axis, .chart-label { + font-size: 10px; + fill: #959ba1; - // baselines - .axis { - font-size: 10px; - fill: #6a737d; - - line { - stroke: rgba(27,31,35,0.1); - } + line { + stroke: rgba(27,31,35,0.1); } } From 2b200bcf7f785b3fd4add57633c501e6d724dde9 Mon Sep 17 00:00:00 2001 From: pratu16x7 Date: Tue, 12 Sep 2017 10:52:25 +0530 Subject: [PATCH 4/5] [heatmap] choose continuous for dashboard --- frappe/public/css/form.css | 4 ++++ frappe/public/js/frappe/form/dashboard.js | 2 +- frappe/public/js/frappe/form/templates/form_dashboard.html | 2 +- frappe/public/js/frappe/ui/graphs.js | 2 +- frappe/public/less/form.less | 5 +++++ 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/frappe/public/css/form.css b/frappe/public/css/form.css index c83c71833a..f03f8d987d 100644 --- a/frappe/public/css/form.css +++ b/frappe/public/css/form.css @@ -98,6 +98,10 @@ .form-dashboard-section:last-child { border-bottom: none; } +.form-heatmap .heatmap { + display: flex; + justify-content: center; +} .form-heatmap .heatmap-message { margin-top: 10px; } diff --git a/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js index c7ce98e537..23e13ac823 100644 --- a/frappe/public/js/frappe/form/dashboard.js +++ b/frappe/public/js/frappe/form/dashboard.js @@ -338,7 +338,7 @@ frappe.ui.form.Dashboard = Class.extend({ parent: this.heatmap_area.find("#heatmap-" + frappe.model.scrub(this.frm.doctype)), height: 100, start: new Date(moment().subtract(1, 'year').toDate()), - discrete_domains: 1 + discrete_domains: 0 }); // center the heatmap diff --git a/frappe/public/js/frappe/form/templates/form_dashboard.html b/frappe/public/js/frappe/form/templates/form_dashboard.html index c41929df73..110cff3a7c 100644 --- a/frappe/public/js/frappe/form/templates/form_dashboard.html +++ b/frappe/public/js/frappe/form/templates/form_dashboard.html @@ -2,7 +2,7 @@ diff --git a/frappe/public/js/frappe/ui/graphs.js b/frappe/public/js/frappe/ui/graphs.js index 041928cebe..b79bbc9069 100644 --- a/frappe/public/js/frappe/ui/graphs.js +++ b/frappe/public/js/frappe/ui/graphs.js @@ -649,7 +649,7 @@ frappe.ui.HeatMap = class HeatMap extends frappe.ui.Graph { } set_width() { - this.base_width = (this.no_of_cols + 14) * 12; + this.base_width = (this.no_of_cols) * 12; } setup_components() { diff --git a/frappe/public/less/form.less b/frappe/public/less/form.less index 854fa53f66..afec19cd44 100644 --- a/frappe/public/less/form.less +++ b/frappe/public/less/form.less @@ -136,6 +136,11 @@ .form-heatmap { + .heatmap { + display: flex; + justify-content: center; + } + .heatmap-message { margin-top: 10px; } From 6fb4984e412153415760cb3cde2baed002b572cc Mon Sep 17 00:00:00 2001 From: pratu16x7 Date: Tue, 12 Sep 2017 11:23:39 +0530 Subject: [PATCH 5/5] remove cal_heatmap --- .eslintrc | 1 - frappe/desk/page/activity/activity.js | 30 +- frappe/public/build.json | 2 - frappe/public/css/cal-heatmap.css | 140 - frappe/public/js/frappe/form/dashboard.js | 1 + frappe/public/js/frappe/ui/graphs.js | 16 +- frappe/public/js/lib/cal-heatmap.js | 3471 --------------------- 7 files changed, 19 insertions(+), 3642 deletions(-) delete mode 100755 frappe/public/css/cal-heatmap.css delete mode 100755 frappe/public/js/lib/cal-heatmap.js diff --git a/.eslintrc b/.eslintrc index ade1623262..f94193e00e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -49,7 +49,6 @@ "moment": true, "hljs": true, "Awesomplete": true, - "CalHeatMap": true, "Sortable": true, "Showdown": true, "Taggle": true, diff --git a/frappe/desk/page/activity/activity.js b/frappe/desk/page/activity/activity.js index c74800cc82..97e81c86d4 100644 --- a/frappe/desk/page/activity/activity.js +++ b/frappe/desk/page/activity/activity.js @@ -180,30 +180,12 @@ frappe.activity.render_heatmap = function(page) { method: "frappe.desk.page.activity.activity.get_heatmap_data", callback: function(r) { if(r.message) { - var legend = []; - var max = Math.max.apply(this, $.map(r.message, function(v) { return v })); - var legend = [cint(max/5), cint(max*2/5), cint(max*3/5), cint(max*4/5)]; - var heatmap = new CalHeatMap(); - heatmap.init({ - itemSelector: ".heatmap", - domain: "month", - subDomain: "day", - start: moment().subtract(1, 'year').add(1, 'month').toDate(), - cellSize: 9, - cellPadding: 2, - domainGutter: 2, - range: 12, - domainLabelFormat: function(date) { - return moment(date).format("MMM").toUpperCase(); - }, - displayLegend: false, - legend: legend, - tooltip: true, - subDomainTitleFormat: { - empty: "{date}", - filled: "{count} actions on {date}" - }, - subDomainDateFormat: "%d-%b" + var heatmap = new frappe.ui.HeatMap({ + parent: $(".heatmap"), + height: 100, + start: new Date(moment().subtract(1, 'year').toDate()), + count_label: "actions", + discrete_domains: 0 }); heatmap.update(r.message); diff --git a/frappe/public/build.json b/frappe/public/build.json index 15dc9e313b..913adfe735 100755 --- a/frappe/public/build.json +++ b/frappe/public/build.json @@ -97,7 +97,6 @@ "public/css/bootstrap.css", "public/css/font-awesome.css", "public/css/octicons/octicons.css", - "public/css/cal-heatmap.css", "public/css/c3.min.css", "public/css/desk.css", "public/css/indicator.css", @@ -231,7 +230,6 @@ ], "js/d3.min.js": [ "public/js/lib/d3.min.js", - "public/js/lib/cal-heatmap.js", "public/js/lib/c3.min.js" ], "css/module.min.css": [ diff --git a/frappe/public/css/cal-heatmap.css b/frappe/public/css/cal-heatmap.css deleted file mode 100755 index 66d1323c09..0000000000 --- a/frappe/public/css/cal-heatmap.css +++ /dev/null @@ -1,140 +0,0 @@ -/* Cal-HeatMap CSS */ - -.cal-heatmap-container { - display: block; -} - -.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.now -{ - stroke: red -} - -.cal-heatmap-container text.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/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js index 23e13ac823..ec96d2a51a 100644 --- a/frappe/public/js/frappe/form/dashboard.js +++ b/frappe/public/js/frappe/form/dashboard.js @@ -338,6 +338,7 @@ frappe.ui.form.Dashboard = Class.extend({ parent: this.heatmap_area.find("#heatmap-" + frappe.model.scrub(this.frm.doctype)), height: 100, start: new Date(moment().subtract(1, 'year').toDate()), + count_label: "items", discrete_domains: 0 }); diff --git a/frappe/public/js/frappe/ui/graphs.js b/frappe/public/js/frappe/ui/graphs.js index b79bbc9069..4cb282d17f 100644 --- a/frappe/public/js/frappe/ui/graphs.js +++ b/frappe/public/js/frappe/ui/graphs.js @@ -618,6 +618,7 @@ frappe.ui.HeatMap = class HeatMap extends frappe.ui.Graph { subdomain = '', data = {}, discrete_domains = 0, + count_label = '', // remove these graph related args y = [], @@ -631,6 +632,8 @@ frappe.ui.HeatMap = class HeatMap extends frappe.ui.Graph { this.data = data; this.discrete_domains = discrete_domains; + this.count_label = count_label; + this.legend_colors = ['#ebedf0', '#c6e48b', '#7bc96f', '#239a3b', '#196127']; } @@ -717,6 +720,11 @@ frappe.ui.HeatMap = class HeatMap extends frappe.ui.Graph { color_index = this.get_max_checkpoint(data_value, this.distribution); } + if(this.data[Math.round(timestamp)]) { + data_value = this.data[Math.round(timestamp)]; + color_index = this.get_max_checkpoint(data_value, this.distribution); + } + let x = 13 + (index + week_col_change) * 12; data_group.add(this.snap.rect(x, y, square_side, square_side).attr({ @@ -745,9 +753,9 @@ frappe.ui.HeatMap = class HeatMap extends frappe.ui.Graph { render_month_labels() { this.first_month_label = 1; - if (this.first_week_start.getDate() > 8) { - this.first_month_label = 0; - } + // if (this.first_week_start.getDate() > 8) { + // this.first_month_label = 0; + // } this.last_month_label = 1; let first_month = this.months.shift(); @@ -785,7 +793,7 @@ frappe.ui.HeatMap = class HeatMap extends frappe.ui.Graph { let width = parseInt(subdomain.attr('width')); let x = p_off.left - g_off.left + (width+2)/2; let y = p_off.top - g_off.top - (width+2)/2; - let value = count + ' items'; + let value = count + ' ' + this.count_label; let name = ' on ' + month + ' ' + date_parts[0] + ', ' + date_parts[2]; this.tip.set_values(x, y, name, value, [], 1); diff --git a/frappe/public/js/lib/cal-heatmap.js b/frappe/public/js/lib/cal-heatmap.js deleted file mode 100755 index aaa256f3cb..0000000000 --- a/frappe/public/js/lib/cal-heatmap.js +++ /dev/null @@ -1,3471 +0,0 @@ -/*! cal-heatmap v3.5.4 (Mon Aug 24 2015 10:02:24) - * --------------------------------------------- - * Cal-Heatmap is a javascript module to create calendar heatmap to visualize time series data - * https://github.com/wa0x6e/cal-heatmap - * Licensed under the MIT license - * Copyright 2014 Wan Qi Chen - */ - -var d3 = typeof require === "function" ? require("d3") : window.d3; - -var CalHeatMap = function() { - "use strict"; - - var self = this; - - this.allowedDataType = ["json", "csv", "tsv", "txt"]; - - // Default settings - this.options = { - // selector string of the container to append the graph to - // Accept any string value accepted by document.querySelector or CSS3 - // or an Element object - itemSelector: "#cal-heatmap", - - // Whether to paint the calendar on init() - // Used by testsuite to reduce testing time - paintOnLoad: true, - - // ================================================ - // DOMAIN - // ================================================ - - // Number of domain to display on the graph - range: 12, - - // Size of each cell, in pixel - cellSize: 10, - - // Padding between each cell, in pixel - cellPadding: 2, - - // For rounded subdomain rectangles, in pixels - cellRadius: 0, - - domainGutter: 2, - - domainMargin: [0, 0, 0, 0], - - domain: "hour", - - subDomain: "min", - - // Number of columns to split the subDomains to - // If not null, will takes precedence over rowLimit - colLimit: null, - - // Number of rows to split the subDomains to - // Will be ignored if colLimit is not null - rowLimit: null, - - // First day of the week is Monday - // 0 to start the week on Sunday - weekStartOnMonday: true, - - // Start date of the graph - // @default now - start: new Date(), - - minDate: null, - - maxDate: null, - - // ================================================ - // DATA - // ================================================ - - // Data source - // URL, where to fetch the original datas - data: "", - - // Data type - // Default: json - dataType: this.allowedDataType[0], - - // Payload sent when using POST http method - // Leave to null (default) for GET request - // Expect a string, formatted like "a=b;c=d" - dataPostPayload: null, - - // Whether to consider missing date:value from the datasource - // as equal to 0, or just leave them as missing - considerMissingDataAsZero: false, - - // Load remote data on calendar creation - // When false, the calendar will be left empty - loadOnInit: true, - - // Calendar orientation - // false: display domains side by side - // true : display domains one under the other - verticalOrientation: false, - - // Domain dynamic width/height - // The width on a domain depends on the number of - domainDynamicDimension: true, - - // Domain Label properties - label: { - // valid: top, right, bottom, left - position: "bottom", - - // Valid: left, center, right - // Also valid are the direct svg values: start, middle, end - align: "center", - - // By default, there is no margin/padding around the label - offset: { - x: 0, - y: 0 - }, - - rotate: null, - - // Used only on vertical orientation - width: 100, - - // Used only on horizontal orientation - height: null - }, - - // ================================================ - // LEGEND - // ================================================ - - // Threshold for the legend - legend: [10, 20, 30, 40], - - // Whether to display the legend - displayLegend: true, - - legendCellSize: 10, - - legendCellPadding: 2, - - legendMargin: [0, 0, 0, 0], - - // Legend vertical position - // top: place legend above calendar - // bottom: place legend below the calendar - legendVerticalPosition: "bottom", - - // Legend horizontal position - // accepted values: left, center, right - legendHorizontalPosition: "left", - - // Legend rotation - // accepted values: horizontal, vertical - legendOrientation: "horizontal", - - // Objects holding all the heatmap different colors - // null to disable, and use the default css styles - // - // Examples: - // legendColors: { - // min: "green", - // max: "red", - // empty: "#ffffff", - // base: "grey", - // overflow: "red" - // } - legendColors: null, - - // ================================================ - // HIGHLIGHT - // ================================================ - - // List of dates to highlight - // Valid values: - // - []: don't highlight anything - // - "now": highlight the current date - // - an array of Date objects: highlight the specified dates - highlight: [], - - // ================================================ - // TEXT FORMATTING / i18n - // ================================================ - - // Name of the items to represent in the calendar - itemName: ["item", "items"], - - // Formatting of the domain label - // @default: null, will use the formatting according to domain type - // Accept a string used as specifier by d3.time.format() - // or a function - // - // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting - // for accepted date formatting used by d3.time.format() - domainLabelFormat: null, - - // Formatting of the title displayed when hovering a subDomain cell - subDomainTitleFormat: { - empty: "{date}", - filled: "{count} {name} {connector} {date}" - }, - - // Formatting of the {date} used in subDomainTitleFormat - // @default: null, will use the formatting according to subDomain type - // Accept a string used as specifier by d3.time.format() - // or a function - // - // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting - // for accepted date formatting used by d3.time.format() - subDomainDateFormat: null, - - // Formatting of the text inside each subDomain cell - // @default: null, no text - // Accept a string used as specifier by d3.time.format() - // or a function - // - // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting - // for accepted date formatting used by d3.time.format() - subDomainTextFormat: null, - - // Formatting of the title displayed when hovering a legend cell - legendTitleFormat: { - lower: "less than {min} {name}", - inner: "between {down} and {up} {name}", - upper: "more than {max} {name}" - }, - - // Animation duration, in ms - animationDuration: 500, - - nextSelector: false, - - previousSelector: false, - - itemNamespace: "cal-heatmap", - - tooltip: false, - - // ================================================ - // EVENTS CALLBACK - // ================================================ - - // Callback when clicking on a time block - onClick: null, - - // Callback after painting the empty calendar - // Can be used to trigger an API call, once the calendar is ready to be filled - afterLoad: null, - - // Callback after loading the next domain in the calendar - afterLoadNextDomain: null, - - // Callback after loading the previous domain in the calendar - afterLoadPreviousDomain: null, - - // Callback after finishing all actions on the calendar - onComplete: null, - - // Callback after fetching the datas, but before applying them to the calendar - // Used mainly to convert the datas if they're not formatted like expected - // Takes the fetched "data" object as argument, must return a json object - // formatted like {timestamp:count, timestamp2:count2}, - afterLoadData: function(data) { return data; }, - - // Callback triggered after calling next(). - // The `status` argument is equal to true if there is no - // more next domain to load - // - // This callback is also executed once, after calling previous(), - // only when the max domain is reached - onMaxDomainReached: null, - - // Callback triggered after calling previous(). - // The `status` argument is equal to true if there is no - // more previous domain to load - // - // This callback is also executed once, after calling next(), - // only when the min domain is reached - onMinDomainReached: null - }; - - this._domainType = { - "min": { - name: "minute", - level: 10, - maxItemNumber: 60, - defaultRowNumber: 10, - defaultColumnNumber: 6, - row: function(d) { return self.getSubDomainRowNumber(d); }, - column: function(d) { return self.getSubDomainColumnNumber(d); }, - position: { - x: function(d) { return Math.floor(d.getMinutes() / self._domainType.min.row(d)); }, - y: function(d) { return d.getMinutes() % self._domainType.min.row(d); } - }, - format: { - date: "%H:%M, %A %B %-e, %Y", - legend: "", - connector: "at" - }, - extractUnit: function(d) { - return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes()).getTime(); - } - }, - "hour": { - name: "hour", - level: 20, - maxItemNumber: function(d) { - switch(self.options.domain) { - case "day": - return 24; - case "week": - return 24 * 7; - case "month": - return 24 * (self.options.domainDynamicDimension ? self.getDayCountInMonth(d): 31); - } - }, - defaultRowNumber: 6, - defaultColumnNumber: function(d) { - switch(self.options.domain) { - case "day": - return 4; - case "week": - return 28; - case "month": - return self.options.domainDynamicDimension ? self.getDayCountInMonth(d): 31; - } - }, - row: function(d) { return self.getSubDomainRowNumber(d); }, - column: function(d) { return self.getSubDomainColumnNumber(d); }, - position: { - x: function(d) { - if (self.options.domain === "month") { - if (self.options.colLimit > 0 || self.options.rowLimit > 0) { - return Math.floor((d.getHours() + (d.getDate()-1)*24) / self._domainType.hour.row(d)); - } - return Math.floor(d.getHours() / self._domainType.hour.row(d)) + (d.getDate()-1)*4; - } else if (self.options.domain === "week") { - if (self.options.colLimit > 0 || self.options.rowLimit > 0) { - return Math.floor((d.getHours() + self.getWeekDay(d)*24) / self._domainType.hour.row(d)); - } - return Math.floor(d.getHours() / self._domainType.hour.row(d)) + self.getWeekDay(d)*4; - } - return Math.floor(d.getHours() / self._domainType.hour.row(d)); - }, - y: function(d) { - var p = d.getHours(); - if (self.options.colLimit > 0 || self.options.rowLimit > 0) { - switch(self.options.domain) { - case "month": - p += (d.getDate()-1) * 24; - break; - case "week": - p += self.getWeekDay(d) * 24; - break; - } - } - return Math.floor(p % self._domainType.hour.row(d)); - } - }, - format: { - date: "%Hh, %A %B %-e, %Y", - legend: "%H:00", - connector: "at" - }, - extractUnit: function(d) { - return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime(); - } - }, - "day": { - name: "day", - level: 30, - maxItemNumber: function(d) { - switch(self.options.domain) { - case "week": - return 7; - case "month": - return self.options.domainDynamicDimension ? self.getDayCountInMonth(d) : 31; - case "year": - return self.options.domainDynamicDimension ? self.getDayCountInYear(d) : 366; - } - }, - defaultColumnNumber: function(d) { - d = new Date(d); - switch(self.options.domain) { - case "week": - return 1; - case "month": - return (self.options.domainDynamicDimension && !self.options.verticalOrientation) ? (self.getWeekNumber(new Date(d.getFullYear(), d.getMonth()+1, 0)) - self.getWeekNumber(d) + 1): 6; - case "year": - return (self.options.domainDynamicDimension ? (self.getWeekNumber(new Date(d.getFullYear(), 11, 31)) - self.getWeekNumber(new Date(d.getFullYear(), 0)) + 1): 54); - } - }, - defaultRowNumber: 7, - row: function(d) { return self.getSubDomainRowNumber(d); }, - column: function(d) { return self.getSubDomainColumnNumber(d); }, - position: { - x: function(d) { - switch(self.options.domain) { - case "week": - return Math.floor(self.getWeekDay(d) / self._domainType.day.row(d)); - case "month": - if (self.options.colLimit > 0 || self.options.rowLimit > 0) { - return Math.floor((d.getDate() - 1)/ self._domainType.day.row(d)); - } - return self.getWeekNumber(d) - self.getWeekNumber(new Date(d.getFullYear(), d.getMonth())); - case "year": - if (self.options.colLimit > 0 || self.options.rowLimit > 0) { - return Math.floor((self.getDayOfYear(d) - 1) / self._domainType.day.row(d)); - } - return self.getWeekNumber(d); - } - }, - y: function(d) { - var p = self.getWeekDay(d); - if (self.options.colLimit > 0 || self.options.rowLimit > 0) { - switch(self.options.domain) { - case "year": - p = self.getDayOfYear(d) - 1; - break; - case "week": - p = self.getWeekDay(d); - break; - case "month": - p = d.getDate() - 1; - break; - } - } - return Math.floor(p % self._domainType.day.row(d)); - } - }, - format: { - date: "%A %B %-e, %Y", - legend: "%e %b", - connector: "on" - }, - extractUnit: function(d) { - return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); - } - }, - "week": { - name: "week", - level: 40, - maxItemNumber: 54, - defaultColumnNumber: function(d) { - d = new Date(d); - switch(self.options.domain) { - case "year": - return self._domainType.week.maxItemNumber; - case "month": - return self.options.domainDynamicDimension ? self.getWeekNumber(new Date(d.getFullYear(), d.getMonth()+1, 0)) - self.getWeekNumber(d) : 5; - } - }, - defaultRowNumber: 1, - row: function(d) { return self.getSubDomainRowNumber(d); }, - column: function(d) { return self.getSubDomainColumnNumber(d); }, - position: { - x: function(d) { - switch(self.options.domain) { - case "year": - return Math.floor(self.getWeekNumber(d) / self._domainType.week.row(d)); - case "month": - return Math.floor(self.getMonthWeekNumber(d) / self._domainType.week.row(d)); - } - }, - y: function(d) { - return self.getWeekNumber(d) % self._domainType.week.row(d); - } - }, - format: { - date: "%B Week #%W", - legend: "%B Week #%W", - connector: "in" - }, - extractUnit: function(d) { - var dt = new Date(d.getFullYear(), d.getMonth(), d.getDate()); - // According to ISO-8601, week number computation are based on week starting on Monday - var weekDay = dt.getDay()-1; - if (weekDay < 0) { - weekDay = 6; - } - dt.setDate(dt.getDate() - weekDay); - return dt.getTime(); - } - }, - "month": { - name: "month", - level: 50, - maxItemNumber: 12, - defaultColumnNumber: 12, - defaultRowNumber: 1, - row: function() { return self.getSubDomainRowNumber(); }, - column: function() { return self.getSubDomainColumnNumber(); }, - position: { - x: function(d) { return Math.floor(d.getMonth() / self._domainType.month.row(d)); }, - y: function(d) { return d.getMonth() % self._domainType.month.row(d); } - }, - format: { - date: "%B %Y", - legend: "%B", - connector: "in" - }, - extractUnit: function(d) { - return new Date(d.getFullYear(), d.getMonth()).getTime(); - } - }, - "year": { - name: "year", - level: 60, - row: function() { return self.options.rowLimit || 1; }, - column: function() { return self.options.colLimit || 1; }, - position: { - x: function() { return 1; }, - y: function() { return 1; } - }, - format: { - date: "%Y", - legend: "%Y", - connector: "in" - }, - extractUnit: function(d) { - return new Date(d.getFullYear()).getTime(); - } - } - }; - - for (var type in this._domainType) { - if (this._domainType.hasOwnProperty(type)) { - var d = this._domainType[type]; - this._domainType["x_" + type] = { - name: "x_" + type, - level: d.type, - maxItemNumber: d.maxItemNumber, - defaultRowNumber: d.defaultRowNumber, - defaultColumnNumber: d.defaultColumnNumber, - row: d.column, - column: d.row, - position: { - x: d.position.y, - y: d.position.x - }, - format: d.format, - extractUnit: d.extractUnit - }; - } - } - - // Record the address of the last inserted domain when browsing - this.lastInsertedSvg = null; - - this._completed = false; - - // Record all the valid domains - // Each domain value is a timestamp in milliseconds - this._domains = d3.map(); - - this.graphDim = { - width: 0, - height: 0 - }; - - this.legendDim = { - width: 0, - height: 0 - }; - - this.NAVIGATE_LEFT = 1; - this.NAVIGATE_RIGHT = 2; - - // Various update mode when using the update() API - this.RESET_ALL_ON_UPDATE = 0; - this.RESET_SINGLE_ON_UPDATE = 1; - this.APPEND_ON_UPDATE = 2; - - this.DEFAULT_LEGEND_MARGIN = 10; - - this.root = null; - this.tooltip = null; - - this._maxDomainReached = false; - this._minDomainReached = false; - - this.domainPosition = new DomainPosition(); - this.Legend = null; - this.legendScale = null; - - // List of domains that are skipped because of DST - // All times belonging to these domains should be re-assigned to the previous domain - this.DSTDomain = []; - - /** - * Display the graph for the first time - * @return bool True if the calendar is created - */ - this._init = function() { - - self.getDomain(self.options.start).map(function(d) { return d.getTime(); }).map(function(d) { - self._domains.set(d, self.getSubDomain(d).map(function(d) { return {t: self._domainType[self.options.subDomain].extractUnit(d), v: null}; })); - }); - - self.root = d3.select(self.options.itemSelector).append("svg").attr("class", "cal-heatmap-container"); - - self.tooltip = d3.select(self.options.itemSelector) - .attr("style", function() { - var current = d3.select(self.options.itemSelector).attr("style"); - return (current !== null ? current : "") + "position:relative;"; - }) - .append("div") - .attr("class", "ch-tooltip") - ; - - self.root.attr("x", 0).attr("y", 0).append("svg").attr("class", "graph"); - - self.Legend = new Legend(self); - - if (self.options.paintOnLoad) { - _initCalendar(); - } - - return true; - }; - - function _initCalendar() { - self.verticalDomainLabel = (self.options.label.position === "top" || self.options.label.position === "bottom"); - - self.domainVerticalLabelHeight = self.options.label.height === null ? Math.max(25, self.options.cellSize*2): self.options.label.height; - self.domainHorizontalLabelWidth = 0; - - if (self.options.domainLabelFormat === "" && self.options.label.height === null) { - self.domainVerticalLabelHeight = 0; - } - - if (!self.verticalDomainLabel) { - self.domainVerticalLabelHeight = 0; - self.domainHorizontalLabelWidth = self.options.label.width; - } - - self.paint(); - - // =========================================================================// - // ATTACHING DOMAIN NAVIGATION EVENT // - // =========================================================================// - if (self.options.nextSelector !== false) { - d3.select(self.options.nextSelector).on("click." + self.options.itemNamespace, function() { - d3.event.preventDefault(); - return self.loadNextDomain(1); - }); - } - - if (self.options.previousSelector !== false) { - d3.select(self.options.previousSelector).on("click." + self.options.itemNamespace, function() { - d3.event.preventDefault(); - return self.loadPreviousDomain(1); - }); - } - - self.Legend.redraw(self.graphDim.width - self.options.domainGutter - self.options.cellPadding); - self.afterLoad(); - - var domains = self.getDomainKeys(); - - // Fill the graph with some datas - if (self.options.loadOnInit) { - self.getDatas( - self.options.data, - new Date(domains[0]), - self.getSubDomain(domains[domains.length-1]).pop(), - function() { - self.fill(); - self.onComplete(); - } - ); - } else { - self.onComplete(); - } - - self.checkIfMinDomainIsReached(domains[0]); - self.checkIfMaxDomainIsReached(self.getNextDomain().getTime()); - } - - // Return the width of the domain block, without the domain gutter - // @param int d Domain start timestamp - function w(d, outer) { - var width = self.options.cellSize*self._domainType[self.options.subDomain].column(d) + self.options.cellPadding*self._domainType[self.options.subDomain].column(d); - if (arguments.length === 2 && outer === true) { - return width += self.domainHorizontalLabelWidth + self.options.domainGutter + self.options.domainMargin[1] + self.options.domainMargin[3]; - } - return width; - } - - // Return the height of the domain block, without the domain gutter - function h(d, outer) { - var height = self.options.cellSize*self._domainType[self.options.subDomain].row(d) + self.options.cellPadding*self._domainType[self.options.subDomain].row(d); - if (arguments.length === 2 && outer === true) { - height += self.options.domainGutter + self.domainVerticalLabelHeight + self.options.domainMargin[0] + self.options.domainMargin[2]; - } - return height; - } - - /** - * - * - * @param int navigationDir - */ - this.paint = function(navigationDir) { - - var options = self.options; - - if (arguments.length === 0) { - navigationDir = false; - } - - // Painting all the domains - var domainSvg = self.root.select(".graph") - .selectAll(".graph-domain") - .data( - function() { - var data = self.getDomainKeys(); - return navigationDir === self.NAVIGATE_LEFT ? data.reverse(): data; - }, - function(d) { return d; } - ) - ; - - var enteringDomainDim = 0; - var exitingDomainDim = 0; - - // =========================================================================// - // PAINTING DOMAIN // - // =========================================================================// - - var svg = domainSvg - .enter() - .append("svg") - .attr("width", function(d) { - return w(d, true); - }) - .attr("height", function(d) { - return h(d, true); - }) - .attr("x", function(d) { - if (options.verticalOrientation) { - self.graphDim.width = Math.max(self.graphDim.width, w(d, true)); - return 0; - } else { - return getDomainPosition(d, self.graphDim, "width", w(d, true)); - } - }) - .attr("y", function(d) { - if (options.verticalOrientation) { - return getDomainPosition(d, self.graphDim, "height", h(d, true)); - } else { - self.graphDim.height = Math.max(self.graphDim.height, h(d, true)); - return 0; - } - }) - .attr("class", function(d) { - var classname = "graph-domain"; - var date = new Date(d); - switch(options.domain) { - case "hour": - classname += " h_" + date.getHours(); - /* falls through */ - case "day": - classname += " d_" + date.getDate() + " dy_" + date.getDay(); - /* falls through */ - case "week": - classname += " w_" + self.getWeekNumber(date); - /* falls through */ - case "month": - classname += " m_" + (date.getMonth() + 1); - /* falls through */ - case "year": - classname += " y_" + date.getFullYear(); - } - return classname; - }) - ; - - self.lastInsertedSvg = svg; - - function getDomainPosition(domainIndex, graphDim, axis, domainDim) { - var tmp = 0; - switch(navigationDir) { - case false: - tmp = graphDim[axis]; - - graphDim[axis] += domainDim; - self.domainPosition.setPosition(domainIndex, tmp); - return tmp; - - case self.NAVIGATE_RIGHT: - self.domainPosition.setPosition(domainIndex, graphDim[axis]); - - enteringDomainDim = domainDim; - exitingDomainDim = self.domainPosition.getPositionFromIndex(1); - - self.domainPosition.shiftRightBy(exitingDomainDim); - return graphDim[axis]; - - case self.NAVIGATE_LEFT: - tmp = -domainDim; - - enteringDomainDim = -tmp; - exitingDomainDim = graphDim[axis] - self.domainPosition.getLast(); - - self.domainPosition.setPosition(domainIndex, tmp); - self.domainPosition.shiftLeftBy(enteringDomainDim); - return tmp; - } - } - - svg.append("rect") - .attr("width", function(d) { return w(d, true) - options.domainGutter - options.cellPadding; }) - .attr("height", function(d) { return h(d, true) - options.domainGutter - options.cellPadding; }) - .attr("class", "domain-background") - ; - - // =========================================================================// - // PAINTING SUBDOMAINS // - // =========================================================================// - var subDomainSvgGroup = svg.append("svg") - .attr("x", function() { - if (options.label.position === "left") { - return self.domainHorizontalLabelWidth + options.domainMargin[3]; - } else { - return options.domainMargin[3]; - } - }) - .attr("y", function() { - if (options.label.position === "top") { - return self.domainVerticalLabelHeight + options.domainMargin[0]; - } else { - return options.domainMargin[0]; - } - }) - .attr("class", "graph-subdomain-group") - ; - - var rect = subDomainSvgGroup - .selectAll("g") - .data(function(d) { return self._domains.get(d); }) - .enter() - .append("g") - ; - - rect - .append("rect") - .attr("class", function(d) { - return "graph-rect" + self.getHighlightClassName(d.t) + (options.onClick !== null ? " hover_cursor": ""); - }) - .attr("width", options.cellSize) - .attr("height", options.cellSize) - .attr("x", function(d) { return self.positionSubDomainX(d.t); }) - .attr("y", function(d) { return self.positionSubDomainY(d.t); }) - .on("click", function(d) { - if (options.onClick !== null) { - return self.onClick(new Date(d.t), d.v); - } - }) - .call(function(selection) { - if (options.cellRadius > 0) { - selection - .attr("rx", options.cellRadius) - .attr("ry", options.cellRadius) - ; - } - - if (self.legendScale !== null && options.legendColors !== null && options.legendColors.hasOwnProperty("base")) { - selection.attr("fill", options.legendColors.base); - } - - if (options.tooltip) { - selection.on("mouseover", function(d) { - var domainNode = this.parentNode.parentNode; - - self.tooltip - .html(self.getSubDomainTitle(d)) - .attr("style", "display: block;") - ; - - var tooltipPositionX = self.positionSubDomainX(d.t) - self.tooltip[0][0].offsetWidth/2 + options.cellSize/2; - var tooltipPositionY = self.positionSubDomainY(d.t) - self.tooltip[0][0].offsetHeight - options.cellSize/2; - - // Offset by the domain position - tooltipPositionX += parseInt(domainNode.getAttribute("x"), 10); - tooltipPositionY += parseInt(domainNode.getAttribute("y"), 10); - - // Offset by the calendar position (when legend is left/top) - tooltipPositionX += parseInt(self.root.select(".graph").attr("x"), 10); - tooltipPositionY += parseInt(self.root.select(".graph").attr("y"), 10); - - // Offset by the inside domain position (when label is left/top) - tooltipPositionX += parseInt(domainNode.parentNode.getAttribute("x"), 10); - tooltipPositionY += parseInt(domainNode.parentNode.getAttribute("y"), 10); - - self.tooltip.attr("style", - "display: block; " + - "left: " + tooltipPositionX + "px; " + - "top: " + tooltipPositionY + "px;") - ; - }); - - selection.on("mouseout", function() { - self.tooltip - .attr("style", "display:none") - .html(""); - }); - } - }) - ; - - // Appending a title to each subdomain - if (!options.tooltip) { - rect.append("title").text(function(d){ return self.formatDate(new Date(d.t), options.subDomainDateFormat); }); - } - - // =========================================================================// - // PAINTING LABEL // - // =========================================================================// - if (options.domainLabelFormat !== "") { - svg.append("text") - .attr("class", "graph-label") - .attr("y", function(d) { - var y = options.domainMargin[0]; - switch(options.label.position) { - case "top": - y += self.domainVerticalLabelHeight/2; - break; - case "bottom": - y += h(d) + self.domainVerticalLabelHeight/2; - } - - return y + options.label.offset.y * - ( - ((options.label.rotate === "right" && options.label.position === "right") || - (options.label.rotate === "left" && options.label.position === "left")) ? - -1: 1 - ); - }) - .attr("x", function(d){ - var x = options.domainMargin[3]; - switch(options.label.position) { - case "right": - x += w(d); - break; - case "bottom": - case "top": - x += w(d)/2; - } - - if (options.label.align === "right") { - return x + self.domainHorizontalLabelWidth - options.label.offset.x * - (options.label.rotate === "right" ? -1: 1); - } - return x + options.label.offset.x; - - }) - .attr("text-anchor", function() { - switch(options.label.align) { - case "start": - case "left": - return "start"; - case "end": - case "right": - return "end"; - default: - return "middle"; - } - }) - .attr("dominant-baseline", function() { return self.verticalDomainLabel ? "middle": "top"; }) - .text(function(d) { return self.formatDate(new Date(d), options.domainLabelFormat); }) - .call(domainRotate) - ; - } - - function domainRotate(selection) { - switch (options.label.rotate) { - case "right": - selection - .attr("transform", function(d) { - var s = "rotate(90), "; - switch(options.label.position) { - case "right": - s += "translate(-" + w(d) + " , -" + w(d) + ")"; - break; - case "left": - s += "translate(0, -" + self.domainHorizontalLabelWidth + ")"; - break; - } - - return s; - }); - break; - case "left": - selection - .attr("transform", function(d) { - var s = "rotate(270), "; - switch(options.label.position) { - case "right": - s += "translate(-" + (w(d) + self.domainHorizontalLabelWidth) + " , " + w(d) + ")"; - break; - case "left": - s += "translate(-" + (self.domainHorizontalLabelWidth) + " , " + self.domainHorizontalLabelWidth + ")"; - break; - } - - return s; - }); - break; - } - } - - // =========================================================================// - // PAINTING DOMAIN SUBDOMAIN CONTENT // - // =========================================================================// - if (options.subDomainTextFormat !== null) { - rect - .append("text") - .attr("class", function(d) { return "subdomain-text" + self.getHighlightClassName(d.t); }) - .attr("x", function(d) { return self.positionSubDomainX(d.t) + options.cellSize/2; }) - .attr("y", function(d) { return self.positionSubDomainY(d.t) + options.cellSize/2; }) - .attr("text-anchor", "middle") - .attr("dominant-baseline", "central") - .text(function(d){ - return self.formatDate(new Date(d.t), options.subDomainTextFormat); - }) - ; - } - - // =========================================================================// - // ANIMATION // - // =========================================================================// - - if (navigationDir !== false) { - domainSvg.transition().duration(options.animationDuration) - .attr("x", function(d){ - return options.verticalOrientation ? 0: self.domainPosition.getPosition(d); - }) - .attr("y", function(d){ - return options.verticalOrientation? self.domainPosition.getPosition(d): 0; - }) - ; - } - - var tempWidth = self.graphDim.width; - var tempHeight = self.graphDim.height; - - if (options.verticalOrientation) { - self.graphDim.height += enteringDomainDim - exitingDomainDim; - } else { - self.graphDim.width += enteringDomainDim - exitingDomainDim; - } - - // At the time of exit, domainsWidth and domainsHeight already automatically shifted - domainSvg.exit().transition().duration(options.animationDuration) - .attr("x", function(d){ - if (options.verticalOrientation) { - return 0; - } else { - switch(navigationDir) { - case self.NAVIGATE_LEFT: - return Math.min(self.graphDim.width, tempWidth); - case self.NAVIGATE_RIGHT: - return -w(d, true); - } - } - }) - .attr("y", function(d){ - if (options.verticalOrientation) { - switch(navigationDir) { - case self.NAVIGATE_LEFT: - return Math.min(self.graphDim.height, tempHeight); - case self.NAVIGATE_RIGHT: - return -h(d, true); - } - } else { - return 0; - } - }) - .remove() - ; - - // Resize the root container - self.resize(); - }; -}; - -CalHeatMap.prototype = { - - /** - * Validate and merge user settings with default settings - * - * @param {object} settings User settings - * @return {bool} False if settings contains error - */ - /* jshint maxstatements:false */ - init: function(settings) { - "use strict"; - - var parent = this; - - var options = parent.options = mergeRecursive(parent.options, settings); - - // Fatal errors - // Stop script execution on error - validateDomainType(); - validateSelector(options.itemSelector, false, "itemSelector"); - - if (parent.allowedDataType.indexOf(options.dataType) === -1) { - throw new Error("The data type '" + options.dataType + "' is not valid data type"); - } - - if (d3.select(options.itemSelector)[0][0] === null) { - throw new Error("The node '" + options.itemSelector + "' specified in itemSelector does not exists"); - } - - try { - validateSelector(options.nextSelector, true, "nextSelector"); - validateSelector(options.previousSelector, true, "previousSelector"); - } catch(error) { - console.log(error.message); - return false; - } - - // If other settings contains error, will fallback to default - - if (!settings.hasOwnProperty("subDomain")) { - this.options.subDomain = getOptimalSubDomain(settings.domain); - } - - if (typeof options.itemNamespace !== "string" || options.itemNamespace === "") { - console.log("itemNamespace can not be empty, falling back to cal-heatmap"); - options.itemNamespace = "cal-heatmap"; - } - - // Don't touch these settings - var s = ["data", "onComplete", "onClick", "afterLoad", "afterLoadData", "afterLoadPreviousDomain", "afterLoadNextDomain"]; - - for (var k in s) { - if (settings.hasOwnProperty(s[k])) { - options[s[k]] = settings[s[k]]; - } - } - - options.subDomainDateFormat = (typeof options.subDomainDateFormat === "string" || typeof options.subDomainDateFormat === "function" ? options.subDomainDateFormat : this._domainType[options.subDomain].format.date); - options.domainLabelFormat = (typeof options.domainLabelFormat === "string" || typeof options.domainLabelFormat === "function" ? options.domainLabelFormat : this._domainType[options.domain].format.legend); - options.subDomainTextFormat = ((typeof options.subDomainTextFormat === "string" && options.subDomainTextFormat !== "") || typeof options.subDomainTextFormat === "function" ? options.subDomainTextFormat : null); - options.domainMargin = expandMarginSetting(options.domainMargin); - options.legendMargin = expandMarginSetting(options.legendMargin); - options.highlight = parent.expandDateSetting(options.highlight); - options.itemName = expandItemName(options.itemName); - options.colLimit = parseColLimit(options.colLimit); - options.rowLimit = parseRowLimit(options.rowLimit); - if (!settings.hasOwnProperty("legendMargin")) { - autoAddLegendMargin(); - } - autoAlignLabel(); - - /** - * Validate that a queryString is valid - * - * @param {Element|string|bool} selector The queryString to test - * @param {bool} canBeFalse Whether false is an accepted and valid value - * @param {string} name Name of the tested selector - * @throws {Error} If the selector is not valid - * @return {bool} True if the selector is a valid queryString - */ - function validateSelector(selector, canBeFalse, name) { - if (((canBeFalse && selector === false) || selector instanceof Element || typeof selector === "string") && selector !== "") { - return true; - } - throw new Error("The " + name + " is not valid"); - } - - /** - * Return the optimal subDomain for the specified domain - * - * @param {string} domain a domain name - * @return {string} the subDomain name - */ - function getOptimalSubDomain(domain) { - switch(domain) { - case "year": - return "month"; - case "month": - return "day"; - case "week": - return "day"; - case "day": - return "hour"; - default: - return "min"; - } - } - - /** - * Ensure that the domain and subdomain are valid - * - * @throw {Error} when domain or subdomain are not valid - * @return {bool} True if domain and subdomain are valid and compatible - */ - function validateDomainType() { - if (!parent._domainType.hasOwnProperty(options.domain) || options.domain === "min" || options.domain.substring(0, 2) === "x_") { - throw new Error("The domain '" + options.domain + "' is not valid"); - } - - if (!parent._domainType.hasOwnProperty(options.subDomain) || options.subDomain === "year") { - throw new Error("The subDomain '" + options.subDomain + "' is not valid"); - } - - if (parent._domainType[options.domain].level <= parent._domainType[options.subDomain].level) { - throw new Error("'" + options.subDomain + "' is not a valid subDomain to '" + options.domain + "'"); - } - - return true; - } - - /** - * Fine-tune the label alignement depending on its position - * - * @return void - */ - function autoAlignLabel() { - // Auto-align label, depending on it's position - if (!settings.hasOwnProperty("label") || (settings.hasOwnProperty("label") && !settings.label.hasOwnProperty("align"))) { - switch(options.label.position) { - case "left": - options.label.align = "right"; - break; - case "right": - options.label.align = "left"; - break; - default: - options.label.align = "center"; - } - - if (options.label.rotate === "left") { - options.label.align = "right"; - } else if (options.label.rotate === "right") { - options.label.align = "left"; - } - } - - if (!settings.hasOwnProperty("label") || (settings.hasOwnProperty("label") && !settings.label.hasOwnProperty("offset"))) { - if (options.label.position === "left" || options.label.position === "right") { - options.label.offset = { - x: 10, - y: 15 - }; - } - } - } - - /** - * If not specified, add some margin around the legend depending on its position - * - * @return void - */ - function autoAddLegendMargin() { - switch(options.legendVerticalPosition) { - case "top": - options.legendMargin[2] = parent.DEFAULT_LEGEND_MARGIN; - break; - case "bottom": - options.legendMargin[0] = parent.DEFAULT_LEGEND_MARGIN; - break; - case "middle": - case "center": - options.legendMargin[options.legendHorizontalPosition === "right" ? 3 : 1] = parent.DEFAULT_LEGEND_MARGIN; - } - } - - /** - * Expand a number of an array of numbers to an usable 4 values array - * - * @param {integer|array} value - * @return {array} array - */ - function expandMarginSetting(value) { - if (typeof value === "number") { - value = [value]; - } - - if (!Array.isArray(value)) { - console.log("Margin only takes an integer or an array of integers"); - value = [0]; - } - - switch(value.length) { - case 1: - return [value[0], value[0], value[0], value[0]]; - case 2: - return [value[0], value[1], value[0], value[1]]; - case 3: - return [value[0], value[1], value[2], value[1]]; - case 4: - return value; - default: - return value.slice(0, 4); - } - } - - /** - * Convert a string to an array like [singular-form, plural-form] - * - * @param {string|array} value Date to convert - * @return {array} An array like [singular-form, plural-form] - */ - function expandItemName(value) { - if (typeof value === "string") { - return [value, value + (value !== "" ? "s" : "")]; - } - - if (Array.isArray(value)) { - if (value.length === 1) { - return [value[0], value[0] + "s"]; - } else if (value.length > 2) { - return value.slice(0, 2); - } - - return value; - } - - return ["item", "items"]; - } - - function parseColLimit(value) { - return value > 0 ? value : null; - } - - function parseRowLimit(value) { - if (value > 0 && options.colLimit > 0) { - console.log("colLimit and rowLimit are mutually exclusive, rowLimit will be ignored"); - return null; - } - return value > 0 ? value : null; - } - - return this._init(); - - }, - - /** - * Convert a keyword or an array of keyword/date to an array of date objects - * - * @param {string|array|Date} value Data to convert - * @return {array} An array of Dates - */ - expandDateSetting: function(value) { - "use strict"; - - if (!Array.isArray(value)) { - value = [value]; - } - - return value.map(function(data) { - if (data === "now") { - return new Date(); - } - if (data instanceof Date) { - return data; - } - return false; - }).filter(function(d) { return d !== false; }); - }, - - /** - * Fill the calendar by coloring the cells - * - * @param array svg An array of html node to apply the transformation to (optional) - * It's used to limit the painting to only a subset of the calendar - * @return void - */ - fill: function(svg) { - "use strict"; - - var parent = this; - var options = parent.options; - - if (arguments.length === 0) { - svg = parent.root.selectAll(".graph-domain"); - } - - var rect = svg - .selectAll("svg").selectAll("g") - .data(function(d) { return parent._domains.get(d); }) - ; - - /** - * Colorize the cell via a style attribute if enabled - */ - function addStyle(element) { - if (parent.legendScale === null) { - return false; - } - - element.attr("fill", function(d) { - if (d.v === null && (options.hasOwnProperty("considerMissingDataAsZero") && !options.considerMissingDataAsZero)) { - if (options.legendColors.hasOwnProperty("base")) { - return options.legendColors.base; - } - } - - if (options.legendColors !== null && options.legendColors.hasOwnProperty("empty") && - (d.v === 0 || (d.v === null && options.hasOwnProperty("considerMissingDataAsZero") && options.considerMissingDataAsZero)) - ) { - return options.legendColors.empty; - } - - if (d.v < 0 && options.legend[0] > 0 && options.legendColors !== null && options.legendColors.hasOwnProperty("overflow")) { - return options.legendColors.overflow; - } - - return parent.legendScale(Math.min(d.v, options.legend[options.legend.length-1])); - }); - } - - rect.transition().duration(options.animationDuration).select("rect") - .attr("class", function(d) { - - var htmlClass = parent.getHighlightClassName(d.t).trim().split(" "); - var pastDate = parent.dateIsLessThan(d.t, new Date()); - - if (parent.legendScale === null || - (d.v === null && (options.hasOwnProperty("considerMissingDataAsZero") && !options.considerMissingDataAsZero) &&!options.legendColors.hasOwnProperty("base")) - ) { - htmlClass.push("graph-rect"); - } - - if (!pastDate && htmlClass.indexOf("now") === -1) { - 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.options.highlight[i] instanceof Date && this.dateIsEqual(this.options.highlight[i], d)) { - return " highlight" + (this.isNow(this.options.highlight[i]) ? " now": ""); - } - } - } - 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"; - - 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": {}, - "text.highlight": {}, - "text.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; -}