HTML5Charts

From Request Tracker Wiki
Jump to navigation Jump to search

This works in RT 4.0.6. The end result should look something like this:

File:Bar.png
File:Line.png










File:Pie.png








This is a bit hacky, ok, a lot, I'll try to create a proper extension if/when I'll have some time, meanwhile here's how it's done:

First of all, you'll need Chris Leonello's excellent package jqplot. You can download it here:

http://www.jqplot.com/deploy/download/jquery.jqplot.1.0.4r1120.zip

This is the version I'm using, can't guarantee for newer versions.

Perhaps it's a good idea to wander around his site and see what can be achieved with the library. 

Ok, down to business.

  • Unzip somewhere and copy to your /path/to/rt/local/html/NoAuth/js (create the folder if not there) the following files:
excanvas.js, jquery.jqplot.js

from the "plugins" directory:

     jqplot.pieRenderer.js
     jqplot.barRenderer.js
     jqplot.categoryAxisRenderer.js
     jqplot.dateAxisRenderer.js
     jqplot.canvasAxisLabelRenderer.js
     jqplot.canvasAxisTickRenderer.js
     jqplot.canvasTextRenderer.js
     jqplot.cursor.js
     jqplot.pointLabels.js

If you plan to play with other plugins you can copy all the content of the "plugins" directory

  • Copy jquery.jqplot.min.css to /path/to/rt/local/html/NoAuth/css.
  • Go to /path/to/rt/local/html/NoAuth/js and create a file called charts.js with the following content:
function switchTypes(atLoad){
        var isSelected = atLoad ? 'selected="selected"': '';
        if (!jQuery("#PrimaryGroupBy").length){return};
        if (jQuery("#PrimaryGroupBy").val().match(/Hourly|Annually|Monthly|Daily/)){
                if (!jQuery("#ChartStyle option[value='line']").length){
                        jQuery("#ChartStyle").append('<option '+ isSelected + ' value="line">line</option>');
                }
        } else {
                jQuery("#ChartStyle option[value='line']").remove();
        };
    };
  function massageValues(vals, suffix){
        jQuery.each(vals, function(ind, val){
                if(val[0].match(/Not Set/i)){
                        delete vals[ind];
                        return;
                } else {
                        val[0] += suffix;
                }
                val[1] = Number(val[1]);
        });
  };
  function drawChart(divIndex, chartStyle, tk, vals, period ){
        var myDiv = "plot"+divIndex;
        jQuery("#"+myDiv).width(3*screen.availWidth/4);
        jQuery.jqplot.config.enablePlugins = true;
       var plotOpts = {};
       if (chartStyle.match(/^bar/)) {
                plotOpts = jqPlotOpts.bar;
                plotOpts.axes.xaxis.ticks = tk;
                jQuery.each(vals, function(index, value){vals[index] = Number(value)});
        } else if (chartStyle == 'pie') {
                plotOpts = jqPlotOpts.pie;
                jQuery.each(vals, function(index, value){vals[index][1] = Number(value[1])});
        } else if (chartStyle.match(/^line/)){
                var suffix = "";
                var format = "";
                if (period.match(/Annually/)){
                        suffix = "-12-31 0:00AM";
                        format = '%Y';
                } else if (period.match(/Monthly/)){
                        suffix = "-01 0:00AM"
                        format = '%b %Y';
                } else if (period.match(/Daily/)){
                        suffix = " 0:00AM"
                        format = '%#d %b %Y';
                } else if (period.match(/Hourly/)){
                        suffix = ""
                }
                plotOpts = jqPlotOpts.line;
                plotOpts.axes.xaxis.tickOptions.formatString = format;
                massageValues(vals, suffix);
        }
        plotOpts.animate = !jQuery.jqplot.use_excanvas;
        var myPlot = jQuery.jqplot (myDiv, [vals], plotOpts);

   };
 function setupCharts(o){
     jQuery.ajaxSetup( {cache: false });
     jQuery.each(o, function(key, configObj){
        jQuery.get('/Helpers/LoadChart',configObj,
                function(data){
                        drawChart(key, configObj["ChartStyle"], data.ticks, data.vals, configObj["PrimaryGroupBy"]);
                        var i = 0, total = 0;
                        for(i;i<data.ticks.length;i++){
                                var cls = i%2 ? 'evenline' : 'oddline';
                                var value = typeof(data.vals[i]) == 'number' ? data.vals[i] : data.vals[i][1];
                                total += value;
                                var line = '<tr class="' + cls + '"><td class="label collection-as-table">'+data.ticks[i]+
                                   '</td><td class="value collection-as-table">'+value+'</td></tr>';
                                jQuery('#Results'+key).append(line);
                        };
                        var lastcls = i%2 ? 'evenline' : 'oddline';
                        var totline = '<tr class="'+lastcls+'"><td class="label collection-as-table">Total</td><td class="value collection-as-table">'+
                                total+'</td></tr>';
                        jQuery('#Results'+key).append(totline);
         }
        );
        jQuery('#Header'+key).html(configObj["PrimaryGroupBy"]);
      }); //each chartObj
     jQuery.ajaxSetup( {cache: true });
        if (!jQuery("#PrimaryGroupBy").length ||
                jQuery("#PrimaryGroupBy").data('events') != null ){return};
        jQuery("#PrimaryGroupBy").change(function(){switchTypes(false)});
        switchTypes(true);
 };
  • Then a file called config.charts.js, with the following content:
var jqPlotOpts = {
        line: {
              seriesDefaults: {
                         renderer: jQuery.jqplot.LineRenderer,
                         pointLabels: { show:  true },
                         rendererOptions: { animation: {speed: 3000}, smooth: true },
                },
              axes: {
                        xaxis: {
                                renderer: jQuery.jqplot.DateAxisRenderer,
                                tickOptions: { angle: -30 },
                                tickRenderer:jQuery.jqplot.CanvasAxisTickRenderer,
                                labelRenderer: jQuery.jqplot.CanvasAxisLabelRenderer
                        }
                },
              cursor: { show: true, zoom: true }
        },
        pie: {
              seriesDefaults: {
                         renderer: jQuery.jqplot.PieRenderer,
                         rendererOptions: {showDataLabels: true, dataLabels: 'value'}
                },
              legend: { show: true, placement: 'outsideGrid'},
             cursor: { show: false, zoom: false, dblClickReset: false }
        },
        bar: {
              seriesDefaults: {
                         renderer: jQuery.jqplot.BarRenderer,
                         pointLabels: { show: true },
                         rendererOptions: { varyBarColor: true, animation: {speed: 3000} }
                        },
              axes: {
                        xaxis: {
                                renderer: jQuery.jqplot.CategoryAxisRenderer,
                                tickOptions: { angle: -30 },
                                tickRenderer:jQuery.jqplot.CanvasAxisTickRenderer,
                                labelRenderer: jQuery.jqplot.CanvasAxisLabelRenderer
                        }
                 },
              cursor: { show: false, zoom: false, dblClickReset: false }
        }
};
  • Now we need to patch jquery.jqplot.js(dont' ask, some problems with jQuery.noconflict). Create a file called patch_jqplot.txt with this content:
@@ -11055,7 +11055,7 @@
 
 })(jQuery);  
 
-
+(function($){
     var backCompat = $.uiBackCompat !== false;
 
     $.jqplot.effects = {
@@ -11205,7 +11205,8 @@
         }
 
         // catch (effect, speed, ?)
-        if ( $.type( options ) === "number" || $.fx.speeds[ options ]) {
+        //if ( $.type( options ) === "number" || $.fx.speeds[ options ]) {
+        if ( typeof( options ) === "number" || $.fx.speeds[ options ]) {
             callback = speed;
             speed = options;
             options = {};
@@ -11377,5 +11378,5 @@
         });
 
     };
-
-
+ })(jQuery);
+ 
  • Then run:
 patch -u ./jquery.jqplot.js ./patch_jqplot.txt
  • Minify everything into a file called charts.min.js. If you have jsmin installed, you can use this bash code:
for f in  "
     jquery.jqplot.js
      jqplot.pieRenderer.js
      jqplot.barRenderer.js
      jqplot.categoryAxisRenderer.js
      jqplot.dateAxisRenderer.js
      jqplot.canvasAxisLabelRenderer.js
      jqplot.canvasAxisTickRenderer.js
      jqplot.canvasTextRenderer.js
      jqplot.cursor.js
      excanvas.js
      jqplot.pointLabels.js
      config.charts.js
      charts.js
  "; do cat $f | /path/to/rt/bin/jsmin >> charts.min.js; done

Or you might prefer to only concatenate them in order to simplify debugging in the browser. Make sure you use the filename charts.min.js as that's the one loaded in the browser. The debugging is going to be a bit tricky as I'm loading this script dynamically. Sorry.

  • Create a copy of the Charts.html file in the ../local directory like that:
cp /path/to/your/rt/share/html/Search/Chart.html /path/to/your/rt/local/html/Search/Chart.html
  • Patch it. Create a file called patch_Chart_html.txt containing this:
@@ -69,7 +69,6 @@
 }
 
 my $title = loc( "Search results grouped by [_1]", $PrimaryGroupByLabel );
-
 my $saved_search = $m->comp( '/Widgets/SavedSearch:new',
     SearchType   => 'Chart',
     SearchFields => [qw(Query PrimaryGroupBy ChartStyle)] );
@@ -94,6 +93,7 @@
         SavedSearchLoad
         SavedSearchLoadButton
         SavedSearchOwner
+        ChartStyle 
     );
 
     for(@session_fields) {
@@ -110,7 +110,6 @@
     }

 }
-
 </%init>
 <& /Elements/Header, Title => $title &>
 <& /Elements/Tabs, QueryArgs => \%query &>
  • Then patch Chart.html:
 patch -u /path/to/rt/local/html/Search/Chart.html patch_Chart_html.txt
  • Create a file /path/to/rt/local/html/Search/Elements/Chart and put this inside:
<%args>
$Query => "id > 0"
$PrimaryGroupBy => 'Queue'
$ChartStyle => 'bars'
</%args>

<%once>
my $ChartDivIndex = 0;
</%once>
<script type="text/javascript">
 if(window.chartObj === undefined){
         window.chartObj = { <%$ChartDivIndex |n %> : {
                                                ChartStyle: <%$ChartStyle |n, j%>, PrimaryGroupBy:  <% $PrimaryGroupBy |n, j %>, Query: <% $Query |n, j %>
                        }
                     };  
        jQuery(document).ready(function(){
                        jQuery.getScript('/NoAuth/js/charts.min.js', function(){setupCharts(window.chartObj)});
                        jQuery('<link>').appendTo('head').attr({
                                rel: 'stylesheet',
                                type: 'text/css',
                                href: '/NoAuth/css/jquery.jqplot.min.css'
                        });
        });
 } else {
        window.chartObj[<%$ChartDivIndex |n %>] = {
                                                ChartStyle: <%$ChartStyle |n, j%>, PrimaryGroupBy:  <% $PrimaryGroupBy |n, j %>, Query: <% $Query |n, j %>
                        };
 };       
</script>
<div class="chart-wrapper">
<span class="chart image">
<div id=<% "plot".$ChartDivIndex |n %> style="position:relative;height:auto;width:auto"></div>
</span>
<table class="collection-as-table chart" id =<% "Results".$ChartDivIndex |n %>>
<tr>
<th class="collection-as-table" id=<%"Header".$ChartDivIndex |n %>></th>
<th class="collection-as-table">Tickets</th>
</tr>
</table>
% $ChartDivIndex++;
<div class="query"><span class="label"><% loc('Query') %>:</span><span class="value"><% $Query %></span></div>
</div>
  • Create a file called /path/to/your/rt/local/html/Helpers/LoadChart with this content:
% $r->content_type('application/json');
<% $response |n %>
% $m->abort;
<%args>
$Query => "id > 0"
$PrimaryGroupBy => 'Queue'
$ChartStyle => 'bars'
</%args>
<%init>
use JSON;
use RT::Report::Tickets;
$PrimaryGroupBy ||= 'Queue'; # make sure PrimaryGroupBy is not undef
my $called_by = $m->callers(1)->name();
my $tix = RT::Report::Tickets->new( $session{'CurrentUser'} );
my %AllowedGroupings = reverse $tix->Groupings( Query => $Query );
$PrimaryGroupBy = 'Queue' unless exists $AllowedGroupings{$PrimaryGroupBy};
my ($count_name, $value_name) = $tix->SetupGroupings(
    Query => $Query, GroupBy => $PrimaryGroupBy,
);
my %class = (
    Queue => 'RT::Queue',
    Owner => 'RT::User',
    Creator => 'RT::User',
    LastUpdatedBy => 'RT::User',
);
my $class = $class{ $PrimaryGroupBy };

my (@keys, @values);
while ( my $entry = $tix->Next ) {
    if ($class) {
        my $q = $class->new( $session{'CurrentUser'} );
        $q->Load( $entry->LabelValue( $value_name ) );
        push @keys, $q->Name;
    }
    else {
        push @keys, $entry->LabelValue( $value_name );
    }
    $keys[-1] ||= loc('(no value)');
    push @values, $entry->__Value( $count_name );
}
my %data;
my %loc_keys;
foreach my $key (@keys) { $data{$key} = shift @values; $loc_keys{$key} = loc($key); }
my @sorted_keys = map { $loc_keys{$_}} sort { $loc_keys{$a} cmp $loc_keys{$b} } keys %loc_keys;
my @sorted_values = map { $data{$_}} sort { $loc_keys{$a} cmp $loc_keys{$b} } keys %loc_keys;
my $query_string = $m->comp('/Elements/QueryString', %ARGS);

my ($i,$total);
my @ticks = @sorted_keys;
my @vals;
if ($ChartStyle =~ /^bar/) {
        @vals = @sorted_values;
} elsif ($ChartStyle  =~ /^pie|^line/) {
         @vals = map [$_, $data{$_}], @sorted_keys;

}
my $response = to_json({ticks => \@ticks, vals => \@vals });
</%init>

This will be the endpoint of the ajax call that gets the chart data.

 So what's happening then (very succinctly)? 

I moved all the internal "execute the query and give us the results" logic from the ../Elements/Chart component to the ../Helpers/LoadChart component. Almost wholesale, not many changes, just made it return results as json.

The Chart component now does the following:

 - loads the script charts.min.js. I don't want to use the normal @JSFiles path because it's rather heavy, more than 300k and it's only used for the "Chart" or "Dashboard" pages

 - Once loaded, it executes the function SetupCharts. This in turn fires an ajax call to LoadCharts, draws the charts (via js function drawchart) and populates the result tables.

 - There's some hacky logic in there to handle dashboards. If I have more than one chart per page I don't want to load the scripts/create the document.ready() jQuery call more than once. I also need different names for the wrapper divs. I'm not particularly proud of this part, but it works.

 - There's a nice side efect in dashboards: as the charts are now populated via ajax calls, this will happen in parallel (browser permitting).

I added the new style "line", available only for time related charts (CreatedMonthly....). If you pick this style you'll be able to zoom into the chart by selecting an area. Double click to zoom back.

No download as image at the moment I'm afraid, you'll have to use some sniping tool in order to copy them.