瀏覽代碼

Graph additions (#3776)

* remove hardcoded height and width

* [graph] relative measures, offsets, generalise all base jobs

* Multiple datasets! (individually colored)

* [graph] Tooltips

* [graph] animate and change values

* [graph] percentage graph

* [graph] custom height, window resize refresh

* [graph] style changes

* [graph] update docs

* [graphs] make graphs.less

* [graphs] prefix classes with 'graph-', nest inside parent class
version-14
Prateeksha Singh 7 年之前
committed by Rushabh Mehta
父節點
當前提交
7fa8148d33
共有 18 個文件被更改,包括 1291 次插入555 次删除
  1. +3
    -1
      .eslintrc
  2. 二進制
      frappe/docs/assets/img/desk/animated_line_graph.gif
  3. 二進制
      frappe/docs/assets/img/desk/bar_graph.png
  4. 二進制
      frappe/docs/assets/img/desk/line_graph.png
  5. 二進制
      frappe/docs/assets/img/desk/line_graph_sales.png
  6. 二進制
      frappe/docs/assets/img/desk/percentage_graph.png
  7. +94
    -55
      frappe/docs/user/en/guides/desk/making_graphs.md
  8. +3
    -2
      frappe/public/build.json
  9. +0
    -74
      frappe/public/css/form.css
  10. +274
    -0
      frappe/public/css/graphs.css
  11. +2
    -3
      frappe/public/js/frappe/form/dashboard.js
  12. +0
    -308
      frappe/public/js/frappe/ui/graph.js
  13. +569
    -0
      frappe/public/js/frappe/ui/graphs.js
  14. +1
    -1
      frappe/public/js/frappe/views/reports/query_report.js
  15. +4
    -105
      frappe/public/less/form.less
  16. +319
    -0
      frappe/public/less/graphs.less
  17. +1
    -1
      frappe/tests/test_goal.py
  18. +21
    -5
      frappe/utils/goal.py

+ 3
- 1
.eslintrc 查看文件

@@ -118,6 +118,8 @@
"getCookie": true,
"getCookies": true,
"get_url_arg": true,
"QUnit": true
"QUnit": true,
"Snap": true,
"mina": true
}
}

二進制
frappe/docs/assets/img/desk/animated_line_graph.gif 查看文件

Before After
Width: 944  |  Height: 298  |  Size: 300 KiB

二進制
frappe/docs/assets/img/desk/bar_graph.png 查看文件

Before After
Width: 776  |  Height: 296  |  Size: 29 KiB Width: 921  |  Height: 294  |  Size: 27 KiB

二進制
frappe/docs/assets/img/desk/line_graph.png 查看文件

Before After
Width: 776  |  Height: 295  |  Size: 39 KiB

二進制
frappe/docs/assets/img/desk/line_graph_sales.png 查看文件

Before After
Width: 1104  |  Height: 334  |  Size: 60 KiB

二進制
frappe/docs/assets/img/desk/percentage_graph.png 查看文件

Before After
Width: 1132  |  Height: 151  |  Size: 17 KiB

+ 94
- 55
frappe/docs/user/en/guides/desk/making_graphs.md 查看文件

@@ -1,61 +1,100 @@
# Making Graphs

The Frappe UI **Graph** object enables you to render simple line and bar graphs for a discreet set of data points. You can also set special checkpoint values and summary stats.
The Frappe UI **Graph** object enables you to render simple line, bar or percentage graphs for single or multiple discreet sets of data points. You can also set special checkpoint values and summary stats.

### Example: Line graph
Here's is an example of a simple sales graph:

render_graph: function() {
$('.form-graph').empty();

var months = ['Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'];
var values = [2410, 3100, 1700, 1200, 2700, 1600, 2740, 1000, 850, 1500, 400, 2013];

var goal = 2500;
var current_val = 2013;

new frappe.ui.Graph({
parent: $('.form-graph'),
width: 700,
height: 140,
mode: 'line-graph',

title: 'Sales',
subtitle: 'Monthly',
y_values: values,
x_points: months,

specific_values: [
{
name: "Goal",
line_type: "dashed", // "dashed" or "solid"
value: goal
},
],
summary_values: [
{
name: "This month",
color: 'green', // Indicator colors: 'grey', 'blue', 'red',
// 'green', 'orange', 'purple', 'darkgrey',
// 'black', 'yellow', 'lightblue'
value: '₹ ' + current_val
},
{
name: "Goal",
color: 'blue',
value: '₹ ' + goal
},
{
name: "Completed",
color: 'green',
value: (current_val/goal*100).toFixed(1) + "%"
}
]
});
},

<img src="{{docs_base_url}}/assets/img/desk/line_graph.png" class="screenshot">

Setting the mode to 'bar-graph':
Here's an example of a simple sales graph:

// Data
let months = ['August, 2016', 'September, 2016', 'October, 2016', 'November, 2016',
'December, 2016', 'January, 2017', 'February, 2017', 'March, 2017', 'April, 2017',
'May, 2017', 'June, 2017', 'July, 2017'];

let values1 = [24100, 31000, 17000, 12000, 27000, 16000, 27400, 11000, 8500, 15000, 4000, 20130];
let values2 = [17890, 10400, 12350, 20400, 17050, 23000, 7100, 13800, 16000, 20400, 11000, 13000];
let goal = 25000;
let current_val = 20130;

let g = new frappe.ui.Graph({
parent: $('.form-graph').empty(),
height: 200, // optional
mode: 'line', // 'line', 'bar' or 'percentage'

title: 'Sales',
subtitle: 'Monthly',

y: [
{
title: 'Data 1',
values: values1,
formatted: values1.map(d => '$ ' + d),
color: 'green' // Indicator colors: 'grey', 'blue', 'red',
// 'green', 'light-green', 'orange', 'purple', 'darkgrey',
// 'black', 'yellow', 'lightblue'
},
{
title: 'Data 2',
values: values2,
formatted: values2.map(d => '$ ' + d),
color: 'light-green'
}
],

x: {
values: months.map(d => d.substring(0, 3)),
formatted: months
},

specific_values: [
{
name: 'Goal',
line_type: 'dashed', // 'dashed' or 'solid'
value: goal
},
],

summary: [
{
name: 'This month',
color: 'orange',
value: '$ ' + current_val
},
{
name: 'Goal',
color: 'blue',
value: '$ ' + goal
},
{
name: 'Completed',
color: 'green',
value: (current_val/goal*100).toFixed(1) + "%"
}
]
});

<img src="{{docs_base_url}}/assets/img/desk/line_graph_sales.png" class="screenshot">

`bar` mode yeilds:

<img src="{{docs_base_url}}/assets/img/desk/bar_graph.png" class="screenshot">

You can set the `colors` property of `x` to an array of color values for `percentage` mode:

<img src="{{docs_base_url}}/assets/img/desk/percentage_graph.png" class="screenshot">

You can also change the values of an existing graph with a new set of `y` values:

setTimeout(() => {
g.change_values([
{
values: data[2],
formatted: data[2].map(d => d + 'L')
},
{
values: data[3],
formatted: data[3].map(d => d + 'L')
}
]);
}, 1000);

<img src="{{docs_base_url}}/assets/img/desk/animated_line_graph.gif" class="screenshot">

+ 3
- 2
frappe/public/build.json 查看文件

@@ -52,7 +52,8 @@
"public/css/desktop.css",
"public/css/form.css",
"public/css/mobile.css",
"public/css/kanban.css"
"public/css/kanban.css",
"public/css/graphs.css"
],
"css/frappe-rtl.css": [
"public/css/bootstrap-rtl.css",
@@ -164,7 +165,7 @@
"public/js/frappe/query_string.js",

"public/js/frappe/ui/charts.js",
"public/js/frappe/ui/graph.js",
"public/js/frappe/ui/graphs.js",
"public/js/frappe/ui/comment.js",
"public/js/frappe/misc/rating_icons.html",



+ 0
- 74
frappe/public/css/form.css 查看文件

@@ -678,80 +678,6 @@ select.form-control {
padding: 10px;
margin: 10px;
}
.graph-container .graphics {
margin-top: 10px;
padding: 10px 0px;
}
.graph-container .stats-group {
display: flex;
justify-content: space-around;
flex: 1;
}
.graph-container .stats-container {
display: flex;
justify-content: space-around;
}
.graph-container .stats-container .stats {
padding-bottom: 15px;
}
.graph-container .stats-container .stats-title {
color: #8D99A6;
}
.graph-container .stats-container .stats-value {
font-size: 20px;
font-weight: 300;
}
.graph-container .stats-container .stats-description {
font-size: 12px;
color: #8D99A6;
}
.graph-container .stats-container .graph-data .stats-value {
color: #98d85b;
}
.bar-graph .axis,
.line-graph .axis {
font-size: 10px;
fill: #6a737d;
}
.bar-graph .axis line,
.line-graph .axis line {
stroke: rgba(27, 31, 35, 0.1);
}
.data-points circle {
fill: #28a745;
stroke: #fff;
stroke-width: 2;
}
.data-points g.mini {
fill: #98d85b;
}
.data-points path {
fill: none;
stroke: #28a745;
stroke-opacity: 1;
stroke-width: 2px;
}
.line-graph .path {
fill: none;
stroke: #28a745;
stroke-opacity: 1;
stroke-width: 2px;
}
line.dashed {
stroke-dasharray: 5,3;
}
.tick.x-axis-label {
display: block;
}
.tick .specific-value {
text-anchor: start;
}
.tick .y-value-text {
text-anchor: end;
}
.tick .x-value-text {
text-anchor: middle;
}
body[data-route^="Form/Communication"] textarea[data-fieldname="subject"] {
height: 80px !important;
}

+ 274
- 0
frappe/public/css/graphs.css 查看文件

@@ -0,0 +1,274 @@
/* graphs */
.graph-container .graph-focus-margin {
margin: 0px 5%;
}
.graph-container .graph-graphics {
margin-top: 10px;
padding: 10px 0px;
position: relative;
}
.graph-container .graph-stats-group {
display: flex;
justify-content: space-around;
flex: 1;
}
.graph-container .graph-stats-container {
display: flex;
justify-content: space-around;
padding-top: 10px;
}
.graph-container .graph-stats-container .stats {
padding-bottom: 15px;
}
.graph-container .graph-stats-container .stats-title {
color: #8D99A6;
}
.graph-container .graph-stats-container .stats-value {
font-size: 20px;
font-weight: 300;
}
.graph-container .graph-stats-container .stats-description {
font-size: 12px;
color: #8D99A6;
}
.graph-container .graph-stats-container .graph-data .stats-value {
color: #98d85b;
}
.graph-container .bar-graph .axis,
.graph-container .line-graph .axis {
font-size: 10px;
fill: #6a737d;
}
.graph-container .bar-graph .axis line,
.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 {
stroke: #fff;
stroke-width: 2;
}
.graph-container .graph-data-points path {
fill: none;
stroke-opacity: 1;
stroke-width: 2px;
}
.graph-container line.graph-dashed {
stroke-dasharray: 5,3;
}
.graph-container .tick.x-axis-label {
display: block;
}
.graph-container .tick .specific-value {
text-anchor: start;
}
.graph-container .tick .y-value-text {
text-anchor: end;
}
.graph-container .tick .x-value-text {
text-anchor: middle;
}
.graph-container .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;
}
.graph-container .graph-svg-tip.comparison {
padding: 0;
text-align: left;
pointer-events: none;
}
.graph-container .graph-svg-tip.comparison .title {
display: block;
padding: 10px;
margin: 0;
font-weight: 600;
line-height: 1;
pointer-events: none;
}
.graph-container .graph-svg-tip.comparison ul {
margin: 0;
white-space: nowrap;
list-style: none;
}
.graph-container .graph-svg-tip.comparison li {
display: inline-block;
padding: 5px 10px;
}
.graph-container .graph-svg-tip ul,
.graph-container .graph-svg-tip ol {
padding-left: 0;
display: flex;
}
.graph-container .graph-svg-tip ul.data-point-list li {
min-width: 90px;
flex: 1;
}
.graph-container .graph-svg-tip strong {
color: #dfe2e5;
}
.graph-container .graph-svg-tip::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);
}
.graph-container .stroke.grey {
stroke: #F0F4F7;
}
.graph-container .stroke.blue {
stroke: #5e64ff;
}
.graph-container .stroke.red {
stroke: #ff5858;
}
.graph-container .stroke.light-green {
stroke: #98d85b;
}
.graph-container .stroke.green {
stroke: #28a745;
}
.graph-container .stroke.orange {
stroke: #ffa00a;
}
.graph-container .stroke.purple {
stroke: #743ee2;
}
.graph-container .stroke.darkgrey {
stroke: #b8c2cc;
}
.graph-container .stroke.black {
stroke: #36414C;
}
.graph-container .stroke.yellow {
stroke: #FEEF72;
}
.graph-container .stroke.light-blue {
stroke: #7CD6FD;
}
.graph-container .stroke.lightblue {
stroke: #7CD6FD;
}
.graph-container .fill.grey {
fill: #F0F4F7;
}
.graph-container .fill.blue {
fill: #5e64ff;
}
.graph-container .fill.red {
fill: #ff5858;
}
.graph-container .fill.light-green {
fill: #98d85b;
}
.graph-container .fill.green {
fill: #28a745;
}
.graph-container .fill.orange {
fill: #ffa00a;
}
.graph-container .fill.purple {
fill: #743ee2;
}
.graph-container .fill.darkgrey {
fill: #b8c2cc;
}
.graph-container .fill.black {
fill: #36414C;
}
.graph-container .fill.yellow {
fill: #FEEF72;
}
.graph-container .fill.light-blue {
fill: #7CD6FD;
}
.graph-container .fill.lightblue {
fill: #7CD6FD;
}
.graph-container .background.grey {
background: #F0F4F7;
}
.graph-container .background.blue {
background: #5e64ff;
}
.graph-container .background.red {
background: #ff5858;
}
.graph-container .background.light-green {
background: #98d85b;
}
.graph-container .background.green {
background: #28a745;
}
.graph-container .background.orange {
background: #ffa00a;
}
.graph-container .background.purple {
background: #743ee2;
}
.graph-container .background.darkgrey {
background: #b8c2cc;
}
.graph-container .background.black {
background: #36414C;
}
.graph-container .background.yellow {
background: #FEEF72;
}
.graph-container .background.light-blue {
background: #7CD6FD;
}
.graph-container .background.lightblue {
background: #7CD6FD;
}
.graph-container .border-top.grey {
border-top: 3px solid #F0F4F7;
}
.graph-container .border-top.blue {
border-top: 3px solid #5e64ff;
}
.graph-container .border-top.red {
border-top: 3px solid #ff5858;
}
.graph-container .border-top.light-green {
border-top: 3px solid #98d85b;
}
.graph-container .border-top.green {
border-top: 3px solid #28a745;
}
.graph-container .border-top.orange {
border-top: 3px solid #ffa00a;
}
.graph-container .border-top.purple {
border-top: 3px solid #743ee2;
}
.graph-container .border-top.darkgrey {
border-top: 3px solid #b8c2cc;
}
.graph-container .border-top.black {
border-top: 3px solid #36414C;
}
.graph-container .border-top.yellow {
border-top: 3px solid #FEEF72;
}
.graph-container .border-top.light-blue {
border-top: 3px solid #7CD6FD;
}
.graph-container .border-top.lightblue {
border-top: 3px solid #7CD6FD;
}

+ 2
- 3
frappe/public/js/frappe/form/dashboard.js 查看文件

@@ -418,9 +418,8 @@ frappe.ui.form.Dashboard = Class.extend({
this.graph_area.empty().removeClass('hidden');
$.extend(args, {
parent: me.graph_area,
width: 710,
height: 140,
mode: 'line-graph'
mode: 'line',
height: 140
});

new frappe.ui.Graph(args);


+ 0
- 308
frappe/public/js/frappe/ui/graph.js 查看文件

@@ -1,308 +0,0 @@
// specific_values = [
// {
// name: "Average",
// line_type: "dashed", // "dashed" or "solid"
// value: 10
// },

// summary_values = [
// {
// name: "Total",
// color: 'blue', // Indicator colors: 'grey', 'blue', 'red', 'green', 'orange',
// // 'purple', 'darkgrey', 'black', 'yellow', 'lightblue'
// value: 80
// }
// ]

frappe.ui.Graph = class Graph {
constructor({
parent = null,

width = 0, height = 0,
title = '', subtitle = '',

y_values = [],
x_points = [],

specific_values = [],
summary_values = [],

color = '',
mode = '',
} = {}) {

if(Object.getPrototypeOf(this) === frappe.ui.Graph.prototype) {
if(mode === 'line-graph') {
return new frappe.ui.LineGraph(arguments[0]);
} else if(mode === 'bar-graph') {
return new frappe.ui.BarGraph(arguments[0]);
}
}

this.parent = parent;

this.width = width;
this.height = height;

this.title = title;
this.subtitle = subtitle;

this.y_values = y_values;
this.x_points = x_points;

this.specific_values = specific_values;
this.summary_values = summary_values;

this.color = color;
this.mode = mode;

this.$graph = null;

frappe.require("assets/frappe/js/lib/snap.svg-min.js", this.setup.bind(this));
}

setup() {
this.setup_container();
this.refresh();
}

refresh() {
this.setup_values();
this.setup_components();
this.make_y_axis();
this.make_x_axis();
this.make_units();
if(this.specific_values.length > 0) {
this.show_specific_values();
}
this.setup_group();

if(this.summary_values.length > 0) {
this.show_summary();
}
}

setup_container() {
this.container = $('<div>')
.addClass('graph-container')
.append($(`<h6 class="title" style="margin-top: 15px;">${this.title}</h6>`))
.append($(`<h6 class="sub-title uppercase">${this.subtitle}</h6>`))
.append($(`<div class="graphics"></div>`))
.append($(`<div class="stats-container"></div>`))
.appendTo(this.parent);

let $graphics = this.container.find('.graphics');
this.$stats_container = this.container.find('.stats-container');

this.$graph = $('<div>')
.addClass(this.mode)
.appendTo($graphics);

this.$svg = $(`<svg class="svg" width="${this.width}" height="${this.height}"></svg>`);
this.$graph.append(this.$svg);

this.snap = new Snap(this.$svg[0]);
}

setup_values() {
this.upper_graph_bound = this.get_upper_limit_and_parts(this.y_values)[0];
this.y_axis = this.get_y_axis(this.y_values);
this.avg_unit_width = (this.width-100)/(this.x_points.length - 1);
}

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.graph_list = this.snap.g().attr({
class: "data-points",
});

this.specific_y_lines = this.snap.g().attr({
class: "specific axis",
});
}

setup_group() {
this.snap.g(
this.y_axis_group,
this.x_axis_group,
this.graph_list,
this.specific_y_lines
).attr({
transform: "translate(60, 10)" // default
});
}

show_specific_values() {
this.specific_values.map(d => {
this.specific_y_lines.add(this.snap.g(
this.snap.line(0, 0, this.width - 70, 0).attr({
class: d.line_type === "dashed" ? "dashed": ""
}),
this.snap.text(this.width - 95, 0, d.name.toUpperCase()).attr({
dy: ".32em",
class: "specific-value",
})
).attr({
class: "tick",
transform: `translate(0, ${100 - 100/(this.upper_graph_bound/d.value) })`
}));
});
}

show_summary() {
this.summary_values.map(d => {
this.$stats_container.append($(`<div class="stats">
<span class="indicator ${d.color}">${d.name}: ${d.value}</span>
</div>`));
});
}

// Helpers
get_upper_limit_and_parts(array) {
let specific_values = this.specific_values.map(d => d.value);
let max_val = parseInt(Math.max(...array, ...specific_values));
if((max_val+"").length <= 1) {
return [10, 5];
} else {
let multiplier = Math.pow(10, ((max_val+"").length - 1));
let significant = Math.ceil(max_val/multiplier);
if(significant % 2 !== 0) significant++;
let parts = (significant < 5) ? significant : significant/2;
return [significant * multiplier, parts];
}
}

get_y_axis(array) {
let upper_limit, parts;
[upper_limit, parts] = this.get_upper_limit_and_parts(array);
let y_axis = [];
for(var i = 0; i <= parts; i++){
y_axis.push(upper_limit / parts * i);
}
return y_axis;
}
};

frappe.ui.BarGraph = class BarGraph extends frappe.ui.Graph {
constructor(args = {}) {
super(args);
}

setup_values() {
super.setup_values();
this.avg_unit_width = (this.width-50)/(this.x_points.length + 2);
}

make_y_axis() {
this.y_axis.map((point) => {
this.y_axis_group.add(this.snap.g(
this.snap.line(0, 0, this.width, 0),
this.snap.text(-3, 0, point+"").attr({
dy: ".32em",
class: "y-value-text"
})
).attr({
class: "tick",
transform: `translate(0, ${100 - (100/(this.y_axis.length-1) * this.y_axis.indexOf(point)) })`
}));
});
}

make_x_axis() {
this.x_axis_group.attr({
transform: "translate(0,100)"
});
this.x_points.map((point, i) => {
this.x_axis_group.add(this.snap.g(
this.snap.line(0, 0, 0, 6),
this.snap.text(0, 9, point).attr({
dy: ".71em",
class: "x-value-text"
})
).attr({
class: "tick x-axis-label",
transform: `translate(${ ((this.avg_unit_width - 5)*3/2) + i * (this.avg_unit_width + 5) }, 0)`
}));
});
}

make_units() {
this.y_values.map((value, i) => {
this.graph_list.add(this.snap.g(
this.snap.rect(
0,
(100 - 100/(this.upper_graph_bound/value)),
this.avg_unit_width - 5,
100/(this.upper_graph_bound/value)
)
).attr({
class: "bar mini",
transform: `translate(${ (this.avg_unit_width - 5) + i * (this.avg_unit_width + 5) }, 0)`,
}));
});
}
};

frappe.ui.LineGraph = class LineGraph extends frappe.ui.Graph {
constructor(args = {}) {
super(args);
}

make_y_axis() {
this.y_axis.map((point) => {
this.y_axis_group.add(this.snap.g(
this.snap.line(0, 0, -6, 0),
this.snap.text(-9, 0, point+"").attr({
dy: ".32em",
class: "y-value-text"
})
).attr({
class: "tick",
transform: `translate(0, ${100 - (100/(this.y_axis.length-1)
* this.y_axis.indexOf(point)) })`
}));
});
}

make_x_axis() {
this.x_axis_group.attr({
transform: "translate(0,-7)"
});
this.x_points.map((point, i) => {
this.x_axis_group.add(this.snap.g(
this.snap.line(0, 0, 0, this.height - 25),
this.snap.text(0, this.height - 15, point).attr({
dy: ".71em",
class: "x-value-text"
})
).attr({
class: "tick",
transform: `translate(${ i * this.avg_unit_width }, 0)`
}));
});
}

make_units() {
let points_list = [];
this.y_values.map((value, i) => {
let x = i * this.avg_unit_width;
let y = (100 - 100/(this.upper_graph_bound/value));
this.graph_list.add(this.snap.circle( x, y, 4));
points_list.push(x+","+y);
});

this.make_path("M"+points_list.join("L"));
}

make_path(path_str) {
this.graph_list.prepend(this.snap.path(path_str));
}

};

+ 569
- 0
frappe/public/js/frappe/ui/graphs.js 查看文件

@@ -0,0 +1,569 @@
// specific_values = [
// {
// name: "Average",
// line_type: "dashed", // "dashed" or "solid"
// value: 10
// },

// summary = [
// {
// name: "Total",
// color: 'blue', // Indicator colors: 'grey', 'blue', 'red', 'green', 'orange',
// // 'purple', 'darkgrey', 'black', 'yellow', 'lightblue'
// value: 80
// }
// ]

// Graph: Abstract object
frappe.ui.Graph = class Graph {
constructor({
parent = null,
height = 240,

title = '', subtitle = '',

y = [],
x = [],

specific_values = [],
summary = [],

color = 'blue',
mode = '',
}) {

if(Object.getPrototypeOf(this) === frappe.ui.Graph.prototype) {
if(mode === 'line') {
return new frappe.ui.LineGraph(arguments[0]);
} else if(mode === 'bar') {
return new frappe.ui.BarGraph(arguments[0]);
} else if(mode === 'percentage') {
return new frappe.ui.PercentageGraph(arguments[0]);
}
}

this.parent = parent;
this.base_height = height;
this.height = height - 40;

this.translate_x = 60;
this.translate_y = 10;

this.title = title;
this.subtitle = subtitle;

this.y = y;
this.x = x;

this.specific_values = specific_values;
this.summary = summary;

this.color = color;
this.mode = mode;

this.$graph = null;

// Validate all arguments

frappe.require("assets/frappe/js/lib/snap.svg-min.js", this.setup.bind(this));
}

setup() {
this.bind_window_event();
this.refresh();
}

bind_window_event() {
$(window).on('resize orientationChange', () => {
this.refresh();
});
}

refresh() {

this.base_width = this.parent.width() - 20;
this.width = this.base_width - 100;

this.setup_container();

this.setup_values();

this.setup_utils();

this.setup_components();
this.make_graph_components();

this.make_tooltip();

if(this.summary.length > 0) {
this.show_custom_summary();
} else {
this.show_summary();
}
}

setup_container() {
// Graph needs a dedicated parent element
this.parent.empty();

this.container = $('<div>')
.addClass('graph-container')
.append($(`<h6 class="title" style="margin-top: 15px;">${this.title}</h6>`))
.append($(`<h6 class="sub-title uppercase">${this.subtitle}</h6>`))
.append($(`<div class="graph-graphics"></div>`))
.append($(`<div class="graph-stats-container"></div>`))
.appendTo(this.parent);

this.$graphics = this.container.find('.graph-graphics');
this.$stats_container = this.container.find('.graph-stats-container');

this.$graph = $('<div>')
.addClass(this.mode + '-graph')
.appendTo(this.$graphics);

this.$graph.append(this.make_graph_area());
}

make_graph_area() {
this.$svg = $(`<svg class="svg" width="${this.base_width}" height="${this.base_height}"></svg>`);
this.snap = new Snap(this.$svg[0]);
return this.$svg;
}

setup_values() {
// Multiplier
let all_values = this.specific_values.map(d => d.value);
this.y.map(d => {
all_values = all_values.concat(d.values);
});
[this.upper_limit, this.parts] = this.get_upper_limit_and_parts(all_values);
this.multiplier = this.height / this.upper_limit;

// Baselines
this.set_avg_unit_width_and_x_offset();

this.x_axis_values = this.x.values.map((d, i) => this.x_offset + i * this.avg_unit_width);
this.y_axis_values = this.get_y_axis_values(this.upper_limit, this.parts);

// Data points
this.y.map(d => {
d.y_tops = d.values.map( val => this.height - val * this.multiplier );
d.data_units = [];
});

this.calc_min_tops();
}

set_avg_unit_width_and_x_offset() {
this.avg_unit_width = this.width/(this.x.values.length - 1);
this.x_offset = 0;
}

calc_min_tops() {
this.y_min_tops = new Array(this.x_axis_values.length).fill(9999);
this.y.map(d => {
d.y_tops.map( (y_top, i) => {
if(y_top < this.y_min_tops[i]) {
this.y_min_tops[i] = y_top;
}
});
});
}

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.specific_y_lines = this.snap.g().attr({ class: "specific axis" });
}

make_graph_components() {
this.make_y_axis();
this.make_x_axis();

this.y.map((d, i) => {
this.make_units(d.y_tops, d.color, i);
this.make_path(d);
});

if(this.specific_values.length > 0) {
this.show_specific_values();
}
this.setup_group();
}

setup_group() {
this.snap.g(
this.y_axis_group,
this.x_axis_group,
this.data_units,
this.specific_y_lines
).attr({
transform: `translate(${this.translate_x}, ${this.translate_y})`
});
}

// make HORIZONTAL lines for y values
make_y_axis() {
let width, text_end_at = -9, label_class = '', start_at = 0;
if(this.y_axis_mode === 'span') { // long spanning lines
width = this.width + 6;
start_at = -6;
} else if(this.y_axis_mode === 'tick'){ // short label lines
width = -6;
label_class = 'y-axis-label';
}

this.y_axis_values.map((point) => {
this.y_axis_group.add(this.snap.g(
this.snap.line(start_at, 0, width, 0),
this.snap.text(text_end_at, 0, point+"").attr({
dy: ".32em",
class: "y-value-text"
})
).attr({
class: `tick ${label_class}`,
transform: `translate(0, ${this.height - point * this.multiplier })`
}));
});
}

// make VERTICAL lines for x values
make_x_axis() {
let start_at, height, text_start_at, label_class = '';
if(this.x_axis_mode === 'span') { // long spanning lines
start_at = -7;
height = this.height + 15;
text_start_at = this.height + 25;
} else if(this.x_axis_mode === 'tick'){ // short label lines
start_at = this.height;
height = 6;
text_start_at = 9;
label_class = 'x-axis-label';
}

this.x_axis_group.attr({
transform: `translate(0,${start_at})`
});
this.x.values.map((point, i) => {
this.x_axis_group.add(this.snap.g(
this.snap.line(0, 0, 0, height),
this.snap.text(0, text_start_at, point).attr({
dy: ".71em",
class: "x-value-text"
})
).attr({
class: `tick ${label_class}`,
transform: `translate(${ this.x_axis_values[i] }, 0)`
}));
});
}

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);
this.data_units.add(data_unit);
this.y[dataset_index].data_units.push(data_unit);
});
}

make_path() { }

make_tooltip() {
this.tip = $(`<div class="graph-svg-tip comparison">
<span class="title"></span>
<ul class="data-point-list">
</ul>
</div>`).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');

this.bind_tooltip();
}

bind_tooltip() {
this.$graphics.on('mousemove', (e) => {
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;
}
}
} else {
this.tip.attr({
style: `top: 0px; left: 0px; opacity: 0; pointer-events: none;`
});
}
});

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 = $(`<li>
<strong style="display: block;">
${y_set.formatted ? y_set.formatted[i] : y_set.values[i]}
</strong>
${y_set.title ? y_set.title : '' }
</li>`).addClass(`border-top ${y_set.color}`);
this.tip_data_point_list.append($li);
});
}

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": ""
}),
this.snap.text(this.width + 5, 0, d.name.toUpperCase()).attr({
dy: ".32em",
class: "specific-value",
})
).attr({
class: "tick",
transform: `translate(0, ${this.height - d.value * this.multiplier })`
}));
});
}

show_summary() { }

show_custom_summary() {
this.summary.map(d => {
this.$stats_container.append($(`<div class="stats">
<span class="indicator ${d.color}">${d.name}: ${d.value}</span>
</div>`));
});
}

change_values(new_y) {
let u = this.unit_args;
this.y.map((d, i) => {
let new_d = new_y[i];
new_d.y_tops = new_d.values.map(val => this.height - val * this.multiplier);

// below is equal to this.y[i].data_units..
d.data_units.map((unit, j) => {
let current_y_top = d.y_tops[j];
let current_height = this.height - current_y_top;

let new_y_top = new_d.y_tops[j];
let new_height = current_height - (new_y_top - current_y_top);

this.animate[u.type](unit, new_y_top, {new_height: new_height});
});
});

// Replace values and formatted and tops
this.y.map((d, i) => {
let new_d = new_y[i];
[d.values, d.formatted, d.y_tops] = [new_d.values, new_d.formatted, new_d.y_tops];
});

this.calc_min_tops();

// create new x,y pair string and animate path
if(this.y[0].path) {
new_y.map((e, i) => {
let new_points_list = e.y_tops.map((y, i) => (this.x_axis_values[i] + ',' + y));
let new_path_str = "M"+new_points_list.join("L");
this.y[i].path.animate({d:new_path_str}, 300, mina.easein);
});
}
}

// Helpers
get_strwidth(string) {
return string.length * 8;
}

get_upper_limit_and_parts(array) {
let max_val = parseInt(Math.max(...array));
if((max_val+"").length <= 1) {
return [10, 5];
} else {
let multiplier = Math.pow(10, ((max_val+"").length - 1));
let significant = Math.ceil(max_val/multiplier);
if(significant % 2 !== 0) significant++;
let parts = (significant < 5) ? significant : significant/2;
return [significant * multiplier, parts];
}
}

get_y_axis_values(upper_limit, parts) {
let y_axis = [];
for(var i = 0; i <= parts; i++){
y_axis.push(upper_limit / parts * i);
}
return y_axis;
}

// Objects
setup_utils() {
this.draw = {
'bar': (x, y, args, color, index) => {
let total_width = this.avg_unit_width - args.space_width;
let start_x = x - total_width/2;

let width = total_width / args.no_of_datasets;
let current_x = start_x + width * index;
return this.snap.rect(current_x, y, width, this.height - y).attr({
class: `bar mini fill ${color}`
});
},
'dot': (x, y, args, color) => {
return this.snap.circle(x, y, args.radius).attr({
class: `fill ${color}`
});
}
};

this.animate = {
'bar': (bar, new_y, args) => {
bar.animate({height: args.new_height, y: new_y}, 300, mina.easein);
},
'dot': (dot, new_y) => {
dot.animate({cy: new_y}, 300, mina.easein);
}
};
}
};

frappe.ui.BarGraph = class BarGraph extends frappe.ui.Graph {
constructor(args = {}) {
super(args);
}

setup_values() {
var me = this;
super.setup_values();
this.x_offset = this.avg_unit_width;
this.y_axis_mode = 'span';
this.x_axis_mode = 'tick';
this.unit_args = {
type: 'bar',
args: {
space_width: this.y.length > 1 ?
me.avg_unit_width/2 : me.avg_unit_width/8,
no_of_datasets: this.y.length
}
};
}

set_avg_unit_width_and_x_offset() {
this.avg_unit_width = this.width/(this.x.values.length + 1);
this.x_offset = this.avg_unit_width;
}
};

frappe.ui.LineGraph = class LineGraph extends frappe.ui.Graph {
constructor(args = {}) {
super(args);
}

setup_values() {
super.setup_values();
this.y_axis_mode = 'tick';
this.x_axis_mode = 'span';
this.unit_args = {
type: 'dot',
args: { radius: 4 }
};
}

make_path(d) {
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}`});
this.data_units.prepend(d.path);
}
};

frappe.ui.PercentageGraph = class PercentageGraph extends frappe.ui.Graph {
constructor(args = {}) {
super(args);
}

make_graph_area() {
this.$graphics.addClass('graph-focus-margin');
this.$stats_container.addClass('graph-focus-margin').attr({
style: `padding-top: 0px; margin-bottom: 30px;`
});
this.$div = $(`<div class="div" width="${this.base_width}"
height="${this.base_height}">
<div class="progress-chart"></div>
</div>`);
this.$chart = this.$div.find('.progress-chart');
return this.$div;
}

setup_values() {
this.x.totals = this.x.values.map((d, i) => {
let total = 0;
this.y.map(e => {
total += e.values[i];
});
return total;
});

// Calculate x unit distances for tooltips
}

setup_utils() { }
setup_components() {
this.$percentage_bar = $(`<div class="progress">
</div>`).appendTo(this.$chart);
}

make_graph_components() {
let grand_total = this.x.totals.reduce((a, b) => a + b, 0);
this.x.units = [];
this.x.totals.map((total, i) => {
let $part = $(`<div class="progress-bar background ${this.x.colors[i]}"
style="width: ${total*100/grand_total}%"></div>`);
this.x.units.push($part);
this.$percentage_bar.append($part);
});
}

make_tooltip() { }

show_summary() {
let values = this.x.formatted.length > 0 ? this.x.formatted : this.x.values;
this.x.totals.map((d, i) => {
this.$stats_container.append($(`<div class="stats">
<span class="indicator ${this.x.colors[i]}">
<span class="text-muted">${values[i]}:</span>
${d}
</span>
</div>`));
});
}
};

+ 1
- 1
frappe/public/js/frappe/views/reports/query_report.js 查看文件

@@ -43,7 +43,7 @@ frappe.views.QueryReport = Class.extend({
this.wrapper = $("<div>").appendTo(this.page.main);
$('<div class="waiting-area" style="display: none;"></div>\
<div class="no-report-area msg-box no-border" style="display: none;"></div>\
<div class="chart_area" style="border-bottom: 1px solid #d1d8dd; padding-bottom: 1px"></div>\
<div class="chart_area" style="border-bottom: 1px solid #d1d8dd; padding: 0px 5%"></div>\
<div class="results" style="display: none;">\
<div class="result-area" style="height:400px;"></div>\
<button class="btn btn-secondary btn-default btn-xs expand-all hidden" style="margin: 10px;">'+__('Expand All')+'</button>\


+ 4
- 105
frappe/public/less/form.less 查看文件

@@ -708,10 +708,10 @@ select.form-control {
}

.password-strength-indicator {
float: right;
padding: 15px;
margin-top: -41px;
margin-right: -7px;
float: right;
padding: 15px;
margin-top: -41px;
margin-right: -7px;
}

.password-strength-message {
@@ -856,7 +856,6 @@ select.form-control {
}

/* goals */

.goals-page-container {
background-color: #fafbfc;
padding-top: 1px;
@@ -870,106 +869,6 @@ select.form-control {
}
}

.graph-container {
.graphics {
margin-top: 10px;
padding: 10px 0px;
}

.stats-group {
display: flex;
justify-content: space-around;
flex: 1;
}

.stats-container {
display: flex;
justify-content: space-around;

.stats {
padding-bottom: 15px;
}

.stats-title {
color: #8D99A6;
}
.stats-value {
font-size: 20px;
font-weight: 300;
}
.stats-description {
font-size: 12px;
color: #8D99A6;
}
.graph-data .stats-value {
color: #98d85b;
}
}
}

.bar-graph, .line-graph {

.axis {
font-size: 10px;
fill: #6a737d;

line {
stroke: rgba(27,31,35,0.1);
}
}
}

.data-points {
circle {
fill: #28a745;
stroke: #fff;
stroke-width: 2;
}

g.mini {
fill: #98d85b;
}

path {
fill: none;
stroke: #28a745;
stroke-opacity: 1;
stroke-width: 2px;
}
}

.line-graph {
.path {
fill: none;
stroke: #28a745;
stroke-opacity: 1;
stroke-width: 2px;
}
}

line.dashed {
stroke-dasharray: 5,3;
}

.tick {
&.x-axis-label {
display: block;
}

.specific-value {
text-anchor: start;
}

.y-value-text {
text-anchor: end;
}

.x-value-text {
text-anchor: middle;
}
}


body[data-route^="Form/Communication"] textarea[data-fieldname="subject"] {
height: 80px !important;
}


+ 319
- 0
frappe/public/less/graphs.less 查看文件

@@ -0,0 +1,319 @@

/* graphs */
.graph-container {
.graph-focus-margin {
margin: 0px 5%;
}

.graph-graphics {
margin-top: 10px;
padding: 10px 0px;
position: relative;
}

.graph-stats-group {
display: flex;
justify-content: space-around;
flex: 1;
}

.graph-stats-container {
display: flex;
justify-content: space-around;
padding-top: 10px;

.stats {
padding-bottom: 15px;
}

// Custom (impactified) stats style
.stats-title {
color: #8D99A6;
}
.stats-value {
font-size: 20px;
font-weight: 300;
}
.stats-description {
font-size: 12px;
color: #8D99A6;
}
.graph-data .stats-value {
color: #98d85b;
}
}

.bar-graph, .line-graph {

// baselines
.axis {
font-size: 10px;
fill: #6a737d;

line {
stroke: rgba(27,31,35,0.1);
}
}
}

.percentage-graph {
margin-top: 35px;

.progress {
margin-bottom: 0px;
}
}

.graph-data-points {
circle {
// fill: #28a745;
stroke: #fff;
stroke-width: 2;
}

g.mini {
// fill: #98d85b;
}

path {
fill: none;
// stroke: #28a745;
stroke-opacity: 1;
stroke-width: 2px;
}
}

line.graph-dashed {
stroke-dasharray: 5,3;
}

.tick {
&.x-axis-label {
display: block;
}

.specific-value {
text-anchor: start;
}

.y-value-text {
text-anchor: end;
}

.x-value-text {
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;
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.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);
}
}

.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;
}

}

+ 1
- 1
frappe/tests/test_goal.py 查看文件

@@ -31,4 +31,4 @@ class TestGoal(unittest.TestCase):
frappe.db.set_value('Event', docname, 'description', 1)
data = get_monthly_goal_graph_data('Test', 'Event', docname, 'description', 'description', 'description',
'Event', '', 'description', 'creation', 'starts_on = "2014-01-01"', 'count')
self.assertEquals(float(data['y_values'][-1]), 1)
self.assertEquals(float(data['y'][0]['values'][-1]), 1)

+ 21
- 5
frappe/utils/goal.py 查看文件

@@ -76,15 +76,21 @@ def get_monthly_goal_graph_data(title, doctype, docname, goal_value_field, goal_
month_to_value_dict[current_month_year] = current_month_value

months = []
months_formatted = []
values = []
values_formatted = []
for i in xrange(0, 12):
month_value = formatdate(add_months(today(), -i), "MM-yyyy")
month_word = getdate(month_value).strftime('%b')
month_year = getdate(month_value).strftime('%B') + ', ' + getdate(month_value).strftime('%Y')
months.insert(0, month_word)
months_formatted.insert(0, month_year)
if month_value in month_to_value_dict:
values.insert(0, month_to_value_dict[month_value])
val = month_to_value_dict[month_value]
else:
values.insert(0, 0)
val = 0
values.insert(0, val)
values_formatted.insert(0, format_value(val, meta.get_field(goal_total_field), doc))

specific_values = []
summary_values = [
@@ -119,10 +125,20 @@ def get_monthly_goal_graph_data(title, doctype, docname, goal_value_field, goal_
data = {
'title': title,
# 'subtitle':
'y_values': values,
'x_points': months,
'y': [
{
'color': 'green',
'values': values,
'formatted': values_formatted
}
],
'x': {
'values': months,
'formatted': months_formatted
},

'specific_values': specific_values,
'summary_values': summary_values
'summary': summary_values
}

return data

Loading…
取消
儲存