Bug 17282: Ability to create charts for SQL reports

Add a form under report's result that allow to configure and draw a
chart (pie, bar, line and combination).

Pie: Usefull only for a two-column report's result

bar: Horizontal: Can be horizontal or vertical (check/uncheck
horizontal checkbox),
     Group: allows to group columns (stacked bar chart),
     Line: show some columns as line in a bar chart (combination)

line: line chart :)

This patch adds 2 new js libraries: d3js and c3js:
  - c3.min.css
  - c3.min.js
  - d3.min.js

Test plan:
- Apply this patch,
- execute a report,
- click on show chart settings button (in the tool bar),
- draw chart (click on draw button),
- check the chart

Features:
- Include all rows (ignore pagination),
- Download the chart (svg),
- Choose x column and y columns,
- Exclude last line (Rollup)

Signed-off-by: Michal Denar <black23@gmail.com>

Signed-off-by: Josef Moravec <josef.moravec@gmail.com>

Signed-off-by: Nick Clemens <nick@bywatersolutions.com>
This commit is contained in:
Alex Arnaud 2018-09-24 13:09:35 +00:00 committed by Nick Clemens
parent 8b1bda9ed8
commit edb627bcf2
10 changed files with 443 additions and 0 deletions

View file

@ -0,0 +1 @@
.c3 svg{font:10px sans-serif;-webkit-tap-highlight-color:transparent}.c3 line,.c3 path{fill:none;stroke:#000}.c3 text{-webkit-user-select:none;-moz-user-select:none;user-select:none}.c3-bars path,.c3-event-rect,.c3-legend-item-tile,.c3-xgrid-focus,.c3-ygrid{shape-rendering:crispEdges}.c3-chart-arc path{stroke:#fff}.c3-chart-arc text{fill:#fff;font-size:13px}.c3-grid line{stroke:#aaa}.c3-grid text{fill:#aaa}.c3-xgrid,.c3-ygrid{stroke-dasharray:3 3}.c3-text.c3-empty{fill:gray;font-size:2em}.c3-line{stroke-width:1px}.c3-circle._expanded_{stroke-width:1px;stroke:#fff}.c3-selected-circle{fill:#fff;stroke-width:2px}.c3-bar{stroke-width:0}.c3-bar._expanded_{fill-opacity:.75}.c3-target.c3-focused{opacity:1}.c3-target.c3-focused path.c3-line,.c3-target.c3-focused path.c3-step{stroke-width:2px}.c3-target.c3-defocused{opacity:.3!important}.c3-region{fill:#4682b4;fill-opacity:.1}.c3-brush .extent{fill-opacity:.1}.c3-legend-item{font-size:12px}.c3-legend-item-hidden{opacity:.15}.c3-legend-background{opacity:.75;fill:#fff;stroke:#d3d3d3;stroke-width:1}.c3-title{font:14px sans-serif}.c3-tooltip-container{z-index:10}.c3-tooltip{border-collapse:collapse;border-spacing:0;background-color:#fff;empty-cells:show;-webkit-box-shadow:7px 7px 12px -9px #777;-moz-box-shadow:7px 7px 12px -9px #777;box-shadow:7px 7px 12px -9px #777;opacity:.9}.c3-tooltip tr{border:1px solid #CCC}.c3-tooltip th{background-color:#aaa;font-size:14px;padding:2px 5px;text-align:left;color:#FFF}.c3-tooltip td{font-size:13px;padding:3px 6px;background-color:#fff;border-left:1px dotted #999}.c3-tooltip td>span{display:inline-block;width:10px;height:10px;margin-right:6px}.c3-tooltip td.value{text-align:right}.c3-area{stroke-width:0;opacity:.2}.c3-chart-arcs-title{dominant-baseline:middle;font-size:1.3em}.c3-chart-arcs .c3-chart-arcs-background{fill:#e0e0e0;stroke:none}.c3-chart-arcs .c3-chart-arcs-gauge-unit{fill:#000;font-size:16px}.c3-chart-arcs .c3-chart-arcs-gauge-max,.c3-chart-arcs .c3-chart-arcs-gauge-min{fill:#777}.c3-chart-arc .c3-gauge-value{fill:#000}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4222,3 +4222,7 @@ span {
display: none;
}
}
div#makechart ol li {
list-style: none;
}

View file

@ -0,0 +1,96 @@
<div id="makechart" style="display:none;">
[% supposed_x = header_row.shift.cell %]
<fieldset>
<legend>Draw a chart</legend>
<ol>
<li>
<label for="chart-type">Chart type</label>
<select name="chart-type" id="chart-type">
<option value="pie">Pie</option>
<option value="bar">Bar</option>
<option value="line">Line</option>
</select>
<div id="chart-column-horizontal">
<label for="horizontal">Horizontal bar:</label>
<input id="horizontal" name="column-horizontal" type="checkbox">
</div>
</li>
<li>
<label for="x_element">x column:</label>
<select id="x_element" name="x">
<option value="[% supposed_x %]" selected>[% supposed_x %]</option>
[% FOREACH header IN header_row %]
<option value="[% header.cell %]">[% header.cell %]</option>
[% END %]
</select>
</li>
<div>
<label for="include-all">Include all rows (ignore pagination)</label>
<input id="include-all" name="chart-include-all" type="checkbox">
</div>
<label for="exclude-last">Exclude last line (Rollup)</label>
<input id="exclude-last" name="chart-exclude-last" type="checkbox">
[% column = 1 %]
<li>
[% FOREACH header IN header_row %]
<fieldset class="chart-column-conf" id="column_[% column %]" style="display: inline !important;">
<legend>
Column [% column %]
<a class="chart-column-delete" href="#" data-column="[% column %]">
<img src="[% interface %]/[% theme %]/img/x.png" alt="Delete" />
</a>
</legend>
<div>
<label for="y_[% column %]" >y:</label>
<select id="y_[% column %]" name="y">
<option value="[% supposed_x %]" selected>[% supposed_x %]</option>
[% FOREACH h IN header_row %]
[% IF header.cell == h.cell %]
<option value="[% h.cell %]" selected>[% h.cell %]</option>
[% ELSE %]
<option value="[% h.cell %]">[% h.cell %]</option>
[% END %]
[% END %]
</select>
</div>
<div class="chart-column-group">
[% i = 1 %]
<label for="group_[% column %]">Group:</label>
<select id="group_[% column %]" name="group">
[% FOREACH h IN header_row %]
[% IF i == column %]
<option value="[% i %]" selected>[% i %]</option>
[% ELSE %]
<option value="[% i %]">[% i %]</option>
[% END %]
[% i = i + 1 %]
[% END %]
</select>
</div>
<div class="chart-column-line">
<label for="line_[% column %]">line:</label>
<input class="column-line" id="column-line" name="[% header.cell %]" type="checkbox">
</div>
</fieldset>
[% column = column + 1 %]
[% END %]
</li>
<li>
<button id="draw-chart" class="btn btn-default">Draw</button>
</li>
</ol>
</fieldset>
[% item = { cell = supposed_x } %]
[% header_row.unshift(item) %]
<div id="chart"></div>
</div>

View file

@ -60,12 +60,19 @@
<li><a id="csv" href="/cgi-bin/koha/reports/guided_reports.pl?reports=1&phase=Export&amp;format=csv&amp;report_id=[% id | html %]&amp;reportname=[% name |uri %][% PROCESS params %]">[% PROCESS 'delimiter_text.inc' %]</a></li>
<li><a id="tab" href="/cgi-bin/koha/reports/guided_reports.pl?reports=1&phase=Export&amp;format=tab&amp;report_id=[% id | html %]&amp;reportname=[% name |uri %][% PROCESS params %]">Tab separated text</a></li>
<li><a id="ods" href="/cgi-bin/koha/reports/guided_reports.pl?reports=1&phase=Export&amp;format=ods&amp;report_id=[% id | html %]&amp;reportname=[% name |uri %][% PROCESS params %]">Open Document Spreadsheet</a></li>
[% IF (results.json) %]
<li><a id="download-chart" href="#">Download chart</a></li>
[% END %]
</ul>
</div>
<div class="btn-group">
<a class="btn btn-default btn-sm toggle_sql" id="toggle_sql_hid" href="#"><i class="fa fa-eye"></i> Show SQL code</a>
<a class="btn btn-default btn-sm toggle_sql" id="toggle_sql_vis" href="#" style="display:none;"><i class="fa fa-eye-slash"></i> Hide SQL code</a>
</div>
<div class="btn-group">
<a class="btn btn-default btn-sm toggle_chart_settings" id="toggle_chart_settings_hid" href="#"><i class="fa fa-eye"></i> Show chart settings</a>
<a class="btn btn-default btn-sm toggle_chart_settings" id="toggle_chart_settings_vis" href="#" style="display:none;"><i class="fa fa-eye-slash"></i> Hide chart settings</a>
</div>
[% END %]
[% END %]

View file

@ -962,6 +962,12 @@
<h2>JSZip</h2>
<p>The <a href="https://stuk.github.io/jszip/">JSZip</a> JavaScript library is licensed under both the <a href="https://github.com/Stuk/jszip/blob/master/LICENSE.markdown">MIT and GPLv3 Licenses</a>.</p>
<h2>D3.js</h2>
<p><a href="https://d3js.org/">D3.js v3.5.17</a> is a JavaScript library for manipulating documents based on data. It is under the <a href="https://github.com/d3/d3/blob/master/LICENSE">BSD 3-clause Licence</a></p>
<h2>C3.js</h2>
<p><a href="http://c3js.org/">C3.js v0.4.11</a> is a D3-based reusable chart library under the <a href="https://opensource.org/licenses/mit-license.php">MIT licence</a></p>
</div>
<div id="translations">

View file

@ -3,7 +3,9 @@
[% USE KohaDates %]
[% USE Koha %]
[% USE ColumnsSettings %]
[% USE JSON.Escape %]
[% SET footerjs = 1 %]
[%- BLOCK area_name -%]
[%- SWITCH area -%]
[%- CASE 'CIRC' -%]Circulation
@ -36,6 +38,7 @@
[% Asset.css("css/reports.css") | $raw %]
[% Asset.css("css/datatables.css") | $raw %]
[% END %]
[% Asset.css("../lib/d3c3/c3.min.css") | $raw %]
</head>
<body id="rep_guided_reports_start" class="rep">
@ -696,6 +699,7 @@ canned reports and writing custom SQL reports.</p>
[% IF ( execute ) %]
<h1>[% name | html %]</h1>
[% INCLUDE 'chart.inc' %]
[% IF ( notes ) %]<p><span class="label">Notes:</span> [% notes | html %]</p>[% END %]
[% IF ( unlimited_total ) %]<p><span class="label">Total number of results:</span> [% unlimited_total | html %][% IF unlimited_total > limit %] ([% limit | html %] shown)[% END %].</p>[% END %]
<div id="sql_output" style="display:none;"><span class="label">Report SQL:</span><pre>[% sql | html %]</pre></div>
@ -751,6 +755,7 @@ canned reports and writing custom SQL reports.</p>
[% END %]
</table>
</form>
[% END %]
[% END %]
@ -934,12 +939,44 @@ $(document).ready(function() {
</div>
[% MACRO jsinclude BLOCK %]
[% Asset.js("js/charts.js") | $raw %]
[% Asset.js("../lib/d3c3/d3.min.js") | $raw %]
[% Asset.js("../lib/d3c3/c3.min.js") | $raw %]
[% INCLUDE 'calendar.inc' %]
[% IF ( saved1 ) %]
[% INCLUDE 'datatables.inc' %]
[% INCLUDE 'columns_settings.inc' %]
[% END %]
<script>
function hide_bar_element() {
$('#chart-column-horizontal').hide()
$('.chart-column-group').each(function( index ) {
$( this ).hide();
});
$('.chart-column-line').each(function( index ) {
$( this ).hide()
});
}
function show_bar_element() {
$('#chart-column-horizontal').show()
$('.chart-column-group').each(function( index ) {
$( this ).show()
});
$('.chart-column-line').each(function( index ) {
$( this ).show()
});
}
function removeColumn(id) {
$('#'+id).remove();
if ( $('.chart-column-conf').length == 1 ) {
$('.chart-column-delete').remove();
}
}
var MSG_CONFIRM_DELETE = _("Are you sure you want to delete this report? This cannot be undone.");
var group_subgroups = {};
[% FOREACH group IN groups_with_subgroups %]
@ -970,6 +1007,111 @@ $(document).ready(function() {
$(document).ready(function(){
hide_bar_element();
if ( $('.chart-column-conf').length == 1 ) {
$('.chart-column-delete').remove();
}
$(".chart-column-delete").on('click', function(e){
e.preventDefault();
removeColumn('column_' + $(this).data('column'));
})
$('#download-chart').click(function() {
var svg = '<svg>' + $('#chart svg').html() + '</svg>';
this.href = 'data:application/octet-stream;base64,' + btoa(svg);
this.setAttribute('download', 'chart.svg');
});
$('#chart-type').change(function() {
if ($(this).val() == 'bar') {
show_bar_element();
}
else {
hide_bar_element();
}
});
$('#download-chart').hide();
var chart;
[% IF results && !errors %]
$('#draw-chart').click(function() {
var btn_text = $("#draw-chart").html();
$("#draw-chart").html(_("Loading..."));
var x_elements = $('select[name="x"]').val();
var y_elements = [];
var groups = [];
var lines = [];
var options = {};
headers = [% header_row.json %];
var results;
if ($('input[name="chart-include-all"]').prop('checked')) {
results = [% allresults.json %]
}
else {
results = [% results.json %]
}
if ($('input[name="chart-exclude-last"]').prop('checked')) {
results.splice(-1, 1);
}
$('select[name="y"]').each(function( index ) {
y_elements.push( $(this).val() );
});
$('select[name="group"]').each(function( index ) {
groups.push( $(this).val() );
});
$('.column-line').each(function( index ) {
if ($(this).prop('checked')) {
lines.push( $(this).attr('name') );
}
});
// Remove deleted columns from headers and results.
var deleted_indexes = [];
var kept_headers = [];
$.each(headers, function(index, value) {
if (value.cell != x_elements && $.inArray(value.cell, y_elements) === -1) {
// This header is neither a x element nor in y elements. Don't need it.
deleted_indexes.push(index);
}
else {
kept_headers.push({cell: value.cell});
}
});
// Remove coresponding cells.
var kept_results = [];
$.each(results, function(index, value) {
var line = {};
line['cells'] = [];
$.each(value.cells, function(i, val) {
if ($.inArray(i, deleted_indexes) === -1) {
line['cells'].push({cell: val.cell});
}
});
kept_results.push(line);
});
options.type = $('select[name="chart-type"]').val();
options.horizontal = $('input[name="column-horizontal"]').prop('checked');
options.lines = lines;
chart = create_chart(kept_headers, kept_results, x_elements, y_elements, groups, options);
$('#chart').prepend('<div style="font-size: 1rem; text-align: center;">' + "[% name %]" + '</div>');
$('#download-chart').show();
$("#draw-chart").html(_(btn_text));
$("html, body").animate({ scrollTop: $(document).height() }, "slow");
});
[% END %]
$('[data-toggle="tooltip"]').tooltip();
var columns_settings = [% ColumnsSettings.GetColumns( 'reports', 'saved-sql', 'table_reports', 'json' ) | $raw %];
@ -1108,6 +1250,12 @@ $(document).ready(function() {
$("#toggle_sql_vis").toggle();
});
$(".toggle_chart_settings").click(function(){
$("#makechart").toggle();
$("#toggle_chart_settings_hid").toggle();
$("#toggle_chart_settings_vis").toggle();
});
$("#table_reports").delegate(".confirmdelete", 'click', function(){
$(this).parents('tr').attr("class","warn");
if(confirm(_("Are you sure you want to delete this saved report?"))){

View file

@ -0,0 +1,163 @@
function create_chart(headers, results, x_element, y_elements, y_groups, options) {
var type = options.type;
var horizontal = options.horizontal;
var lines = options.lines;
var data;
var axis;
if (type != 'pie') {
var columns = build_columns(headers, results, x_element, y_elements);
var groups = build_group(y_elements, y_groups);
var x_values = build_xvalues(headers, results, x_element);
axis = {
x: {
type: 'category',
categories: x_values
}
};
data = {
columns: columns,
groups: groups,
type: type,
};
}
else {
var columns = build_pie_columns(headers, results, x_element);
data = {
columns: columns,
type: type,
};
}
if (type == 'bar') {
var types = {};
$.each(lines, function(index, value) {
types[value] = 'line';
});
data.types = types;
if (horizontal) {
axis.rotated = true;
}
}
var chart = c3.generate({
bindto: '#chart',
data: data,
axis: axis,
});
return chart;
}
function build_pie_columns(headers, results, x_element) {
var columns = [];
var x_index;
//Get x_element index.
$.each(headers, function(index, value) {
if (value.cell == x_element) {
x_index = index;
}
});
$.each(results, function(index, value) {
var cells = value.cells;
$.each( cells, function(i, value) {
if (i == x_index) {
columns[index] = [value.cell];
}
});
$.each( cells, function(i, value) {
if (i != x_index) {
columns[index].push(value.cell);
}
});
});
return columns;
}
function build_xvalues(headers, results, x_element) {
var h_index;
x_values = [];
//Get x_element index.
$.each(headers, function(index, value) {
if (value.cell == x_element) {
h_index = index;
}
});
$.each( results, function (i, value) {
var cells = value.cells;
$.each( cells, function(index, value) {
if (index == h_index) {
x_values.push(value.cell);
}
});
});
return x_values;
}
function build_group(y_elements, y_groups) {
var groups_hash = {};
var groups = [];
$.each(y_groups, function(index, value) {
var related_y = y_elements.shift();
if (!$.isArray(groups_hash[value])) {
groups_hash[value] = [];
}
groups_hash[value].push(related_y);
});
$.each(groups_hash, function(key, value) {
if (value.length !== 0) {
groups.push(value);
}
});
return groups;
}
function build_columns(headers, results, x_element, y_elements) {
var x_index;
var header_index = [];
var y_values = {};
// Keep order of headers using array index.
$.each( headers, function(index, value) {
if (value.cell == x_element) {
x_index = index;
}
header_index.push(value.cell)
});
$.each( y_elements, function(index, element) {
y_values[element] = [element];
});
$.each( results, function (i, value) {
var cells = value.cells;
$.each( cells, function(index, value) {
if (index != x_index) {
y_values[header_index[index]].push(value.cell);
}
});
});
var columns = [];
$.each( y_values, function(key, value) {
columns.push(value);
});
return columns;
}

View file

@ -693,6 +693,7 @@ elsif ($phase eq 'Run this report'){
$notes = $report->notes;
my @rows = ();
my @allrows = ();
# if we have at least 1 parameter, and it's not filled, then don't execute but ask for parameters
if ($sql =~ /<</ && !@sql_params) {
# split on ??. Each odd (2,4,6,...) entry should be a parameter to fill
@ -809,6 +810,7 @@ elsif ($phase eq 'Run this report'){
} else {
my $sql = get_prepped_report( $sql, \@param_names, \@sql_params);
my ( $sth, $errors ) = execute_query( $sql, $offset, $limit, undef, $report_id );
my ($sth2, $errors2) = execute_query($sql);
my $total = nb_rows($sql) || 0;
unless ($sth) {
die "execute_query failed to return sth for report $report_id: $sql";
@ -819,6 +821,10 @@ elsif ($phase eq 'Run this report'){
my @cells = map { +{ cell => $_ } } @$row;
push @rows, { cells => \@cells };
}
while (my $row = $sth2->fetchrow_arrayref()) {
my @cells = map { +{ cell => $_ } } @$row;
push @allrows, { cells => \@cells };
}
}
my $totpages = int($total/$limit) + (($total % $limit) > 0 ? 1 : 0);
@ -828,6 +834,7 @@ elsif ($phase eq 'Run this report'){
}
$template->param(
'results' => \@rows,
'allresults' => \@allrows,
'sql' => $sql,
original_sql => $original_sql,
'id' => $report_id,