File Coverage

blib/lib/SVG/TT/Graph/TimeSeries.pm
Criterion Covered Total %
statement 103 124 83.0
branch 28 50 56.0
condition 21 33 63.6
subroutine 15 16 93.7
pod 2 4 50.0
total 169 227 74.4


line stmt bran cond sub pod time code
1             package SVG::TT::Graph::TimeSeries;
2              
3 3     3   3559 use strict;
  3         6  
  3         87  
4 3     3   14 use Carp;
  3         5  
  3         152  
5 3     3   15 use SVG::TT::Graph;
  3         4  
  3         83  
6 3     3   17 use base qw(SVG::TT::Graph);
  3         4  
  3         418  
7              
8             our $VERSION = $SVG::TT::Graph::VERSION;
9             our $TEMPLATE_FH = \*DATA;
10              
11 3     3   1977 use Data::Dumper;
  3         18491  
  3         201  
12 3     3   1107 use HTTP::Date;
  3         8775  
  3         172  
13 3     3   2711 use DateTime;
  3         1449606  
  3         148  
14 3     3   31 use POSIX;
  3         9  
  3         28  
15              
16              
17             =head1 NAME
18              
19             SVG::TT::Graph::TimeSeries - Create presentation quality SVG line graphs of time series easily
20              
21             =head1 SYNOPSIS
22              
23             use SVG::TT::Graph::TimeSeries;
24              
25             my @data_cpu = ('2003-09-03 09:30:00',23,'2003-09-03 09:45:00',54,'2003-09-03 10:00:00',67,'2003-09-03 10:15:00',12);
26             my @data_disk = ('2003-09-03 09:00:00',12,'2003-09-03 10:00:00',26,'2003-09-03 11:00:00',23);
27              
28             my $graph = SVG::TT::Graph::TimeSeries->new({
29             'height' => '500',
30             'width' => '300',
31             });
32              
33             $graph->add_data({
34             'data' => \@data_cpu,
35             'title' => 'CPU',
36             });
37              
38             $graph->add_data({
39             'data' => \@data_disk,
40             'title' => 'Disk',
41             });
42              
43             print "Content-type: image/svg+xml\n\n";
44             print $graph->burn();
45              
46             =head1 DESCRIPTION
47              
48             This object aims to allow you to easily create high quality
49             SVG line graphs of time series. You can either use the default style sheet
50             or supply your own. Either way there are many options which can
51             be configured to give you control over how the graph is
52             generated - with or without a key, data elements at each point,
53             title, subtitle etc.
54             All times must be given a format parseable by L<HTTP::Date>.
55             The L<DateTime> module is used for all date/time calculations.
56              
57             Note that the module is currently limited to the Unix-style epoch-based
58             date range limited by 32 bit signed integers (around 1902 to 2038).
59              
60             =head1 METHODS
61              
62             =head2 new()
63              
64             use SVG::TT::Graph::TimeSeries;
65              
66             my $graph = SVG::TT::Graph::TimeSeries->new({
67              
68             # Optional - defaults shown
69             'height' => 500,
70             'width' => 300,
71              
72             'show_y_labels' => 1,
73             'scale_divisions' => '',
74             'min_scale_value' => 0,
75             'max_scale_value' => '',
76              
77             'show_x_labels' => 1,
78             'timescale_divisions' => '',
79             'min_timescale_value' => '',
80             'max_timescale_value' => '',
81             'x_label_format' => '%Y-%m-%d %H:%M:%S',
82             'stagger_x_labels' => 0,
83             'rotate_x_labels' => 0,
84             'y_label_formatter' => sub { return @_ },
85             'x_label_formatter' => sub { return @_ },
86              
87             'show_data_points' => 1,
88             'show_data_values' => 1,
89             'rollover_values' => 0,
90              
91             'area_fill' => 0,
92              
93             'show_x_title' => 0,
94             'x_title' => 'X Field names',
95              
96             'show_y_title' => 0,
97             'y_title' => 'Y Scale',
98              
99             'show_graph_title' => 0,
100             'graph_title' => 'Graph Title',
101             'show_graph_subtitle' => 0,
102             'graph_subtitle' => 'Graph Sub Title',
103             'key' => 0,
104             'key_position' => 'right',
105              
106             # Stylesheet defaults
107             'style_sheet' => '/includes/graph.css', # internal stylesheet
108             'random_colors' => 0,
109             });
110              
111             The constructor takes a hash reference with values defaulted to those
112             shown above - with the exception of style_sheet which defaults
113             to using the internal style sheet.
114              
115             =head2 add_data()
116              
117             my @data_cpu = ('2003-09-03 09:30:00',23,'2003-09-03 09:45:00',54,'2003-09-03 10:00:00',67,'2003-09-03 10:15:00',12);
118             or
119             my @data_cpu = (['2003-09-03 09:30:00',23],['2003-09-03 09:45:00',54],['2003-09-03 10:00:00',67],['2003-09-03 10:15:00',12]);
120             or
121             my @data_cpu = (['2003-09-03 09:30:00',23,'23%'],['2003-09-03 09:45:00',54,'54%'],['2003-09-03 10:00:00',67,'67%'],['2003-09-03 10:15:00',12,'12%']);
122              
123             $graph->add_data({
124             'data' => \@data_cpu,
125             'title' => 'CPU',
126             });
127              
128             This method allows you to add data to the graph object. The
129             data are expected to be either a list of scalars (in which case
130             pairs of elements are taken to be time, value pairs) or a list
131             of array references. In the latter case, the first two
132             elements in each referenced array are taken to be time and
133             value, and the optional third element (if present) is used as
134             the text to display for that point for show_data_values and
135             rollover_values (otherwise the value itself is displayed). It
136             can be called several times to add more data sets in.
137              
138             =head2 clear_data()
139              
140             my $graph->clear_data();
141              
142             This method removes all data from the object so that you can
143             reuse it to create a new graph but with the same config options.
144              
145             =head2 burn()
146              
147             print $graph->burn();
148              
149             This method processes the template with the data and
150             config which has been set and returns the resulting SVG.
151              
152             This method will croak unless at least one data set has
153             been added to the graph object.
154              
155             =head2 config methods
156              
157             my $value = $graph->method();
158             my $confirmed_new_value = $graph->method($value);
159              
160             The following is a list of the methods which are available
161             to change the config of the graph object after it has been
162             created.
163              
164             =over 4
165              
166             =item height()
167              
168             Set the height of the graph box, this is the total height
169             of the SVG box created - not the graph it self which auto
170             scales to fix the space.
171              
172             =item width()
173              
174             Set the width of the graph box, this is the total width
175             of the SVG box created - not the graph it self which auto
176             scales to fix the space.
177              
178             =item compress()
179              
180             Whether or not to compress the content of the SVG file (Compress::Zlib required).
181              
182             =item tidy()
183              
184             Whether or not to tidy the content of the SVG file (XML::Tidy required).
185              
186             =item style_sheet()
187              
188             Set the path to an external stylesheet, set to '' if
189             you want to revert back to using the default internal version.
190              
191             Set to "inline:<style>...</style>" with your CSS in between the tags.
192             You can thus override the default style without requireing an external URL.
193              
194             The default stylesheet handles up to 12 data sets. All data series over
195             the 12th will have no style and be in black. If you have over 12 data
196             sets you can assign them all random colors (see the random_color()
197             method) or create your own stylesheet and add the additional settings
198             for the extra data sets.
199              
200             To create an external stylesheet create a graph using the
201             default internal version and copy the stylesheet section to
202             an external file and edit from there.
203              
204             =item random_colors()
205              
206             Use random colors in the internal stylesheet.
207              
208             =item show_data_values()
209              
210             Show the value of each element of data on the graph (or
211             optionally a user-defined label; see add_data).
212              
213             =item show_data_points()
214              
215             Show a small circle on the graph where the line
216             goes from one point to the next.
217              
218             =item rollover_values()
219              
220             Shows data values and data points when the mouse is over the point.
221             Used in combination with show_data_values and/or show_data_points.
222              
223             =item data_value_format()
224              
225             Format specifier to for data values (as per printf).
226              
227             =item max_time_span()
228              
229             Maximum timespan for a line between data points. If this span is exceeded, the points are not connected.
230             This is useful for skipping missing data sections.
231             The expected form is:
232             '<integer> [years | months | days | hours | minutes | seconds]'
233              
234             =item stacked()
235              
236             Accumulates each data set. (i.e. Each point increased by sum of all previous series at same time). Default is 0, set to '1' to show.
237             All data series have the same number of points and must have the same sequence of time values
238             for this option.
239              
240             =item min_scale_value()
241              
242             The point at which the Y axis starts, defaults to '0',
243             if set to '' it will default to the minimum data value.
244              
245             =item max_scale_value()
246              
247             The point at which the Y axis ends,
248             if set to '' it will default to the maximum data value.
249              
250             =item scale_divisions()
251              
252             This defines the gap between markers on the Y axis,
253             default is a 10th of the range, e.g. you will have
254             10 markers on the Y axis. NOTE: do not set this too
255             low - you are limited to 999 markers, after that the
256             graph won't generate.
257              
258             =item show_x_labels()
259              
260             Whether to show labels on the X axis or not, defaults
261             to 1, set to '0' if you want to turn them off.
262              
263             =item x_label_format()
264              
265             Format string for presenting the X axis labels.
266             The POSIX strftime() function is used for formatting
267             after calling the POSIX tzset() function with the timezone
268             specified in timescale_time_zone if present
269             (see strftime man pages and LC_TIME locale information).
270              
271             =item show_y_labels()
272              
273             Whether to show labels on the Y axis or not, defaults
274             to 1, set to '0' if you want to turn them off.
275              
276             =item y_label_format()
277              
278             Format string for presenting the Y axis labels (as per printf).
279              
280             =item timescale_divisions()
281              
282             This defines the gap between markers on the X axis.
283             Default is the entire range (only start and end axis
284             labels).
285             The expected form is:
286             '<integer> [years | months | days | hours | minutes | seconds]'
287             The default time period if not provided is 'days'.
288             These time periods are used by the L<DateTime::Duration> methods.
289              
290             =item timescale_time_zone
291              
292             This determines the time zone used for the date intervals on the X axis.
293             Values are those that L<DateTime> accepts for its constructor's 'time_zone'
294             parameter. The default is 'floating'. If passing in data for a different
295             timezone than that set for your system, note that you also must embed the
296             timezone information into the values passed to 'add_data', for example by
297             formatting your DateTime objects with L<DateTime::Format::RFC3339>.
298              
299             =item stagger_x_labels()
300              
301             This puts the labels at alternative levels so if they
302             are long field names they will not overlap so easily.
303             Default it '0', to turn on set to '1'.
304              
305             =item rotate_x_labels()
306              
307             This turns the X axis labels by 90 degrees.
308             Default it '0', to turn on set to '1'.
309              
310             =item min_timescale_value()
311              
312             This sets the minimum timescale value (X axis).
313             Any data points before this time will not be shown.
314             The date/time is expected in ISO format: YYYY-MM-DD hh:mm:ss.
315              
316             =item max_timescale_value()
317              
318             This sets the maximum timescale value (X axis).
319             Any data points after this time will not be shown.
320             The date/time is expected in ISO format: YYYY-MM-DD hh:mm:ss.
321              
322             =item show_x_title()
323              
324             Whether to show the title under the X axis labels,
325             default is 0, set to '1' to show.
326              
327             =item x_title()
328              
329             What the title under X axis should be, e.g. 'Months'.
330              
331             =item show_y_title()
332              
333             Whether to show the title under the Y axis labels,
334             default is 0, set to '1' to show.
335              
336             =item y_title()
337              
338             What the title under Y axis should be, e.g. 'Sales in thousands'.
339              
340             =item show_graph_title()
341              
342             Whether to show a title on the graph,
343             default is 0, set to '1' to show.
344              
345             =item graph_title()
346              
347             What the title on the graph should be.
348              
349             =item show_graph_subtitle()
350              
351             Whether to show a subtitle on the graph,
352             default is 0, set to '1' to show.
353              
354             =item graph_subtitle()
355              
356             What the subtitle on the graph should be.
357              
358             =item key()
359              
360             Whether to show a key, defaults to 0, set to
361             '1' if you want to show it.
362              
363             =item key_position()
364              
365             Where the key should be positioned, defaults to
366             'right', set to 'bottom' if you want to move it.
367              
368             =item x_label_formatter ()
369              
370             A callback subroutine which will format a label on the x axis. For example:
371              
372             $graph->x_label_formatter( sub { return '$' . $_[0] } );
373              
374             =item y_label_formatter()
375              
376             A callback subroutine which will format a label on the y axis. For example:
377              
378             $graph->y_label_formatter( sub { return '$' . $_[0] } );
379              
380             =back
381              
382             =head1 EXAMPLES
383              
384             For examples look at the project home page
385             http://leo.cuckoo.org/projects/SVG-TT-Graph/
386              
387             =head1 EXPORT
388              
389             None by default.
390              
391             =head1 SEE ALSO
392              
393             L<SVG::TT::Graph>,
394             L<SVG::TT::Graph::Line>,
395             L<SVG::TT::Graph::Bar>,
396             L<SVG::TT::Graph::BarHorizontal>,
397             L<SVG::TT::Graph::BarLine>,
398             L<SVG::TT::Graph::Pie>,
399             L<SVG::TT::Graph::XY>,
400             L<Compress::Zlib>,
401             L<XML::Tidy>
402              
403             =cut
404              
405             sub _init {
406 3     3   7 my $self = shift;
407             }
408              
409             sub _set_defaults {
410 3     3   8 my $self = shift;
411              
412 3         10 my @fields = ();
413              
414             my %default = (
415             'fields' => \@fields,
416              
417             'width' => '500',
418             'height' => '300',
419              
420             'style_sheet' => '',
421             'random_colors' => 0,
422              
423             'show_data_points' => 1,
424             'show_data_values' => 1,
425             'rollover_values' => 0,
426              
427             'max_time_span' => '',
428              
429             'area_fill' => 0,
430              
431             'show_y_labels' => 1,
432             'scale_divisions' => '',
433             'min_scale_value' => '0',
434             'max_scale_value' => '',
435              
436             'stacked' => 0,
437              
438             'show_x_labels' => 1,
439             'stagger_x_labels' => 0,
440             'rotate_x_labels' => 0,
441             'timescale_divisions' => '',
442             'timescale_time_zone' => 'floating',
443             'x_label_format' => '%Y-%m-%d %H:%M:%S',
444 2     2   1065 'x_label_formatter' => sub { return @_ },
445 8     8   288 'y_label_formatter' => sub { return @_ },
446              
447 3         91 'show_x_title' => 0,
448             'x_title' => 'X Field names',
449              
450             'show_y_title' => 0,
451             'y_title' => 'Y Scale',
452              
453             'show_graph_title' => 0,
454             'graph_title' => 'Graph Title',
455             'show_graph_subtitle' => 0,
456             'graph_subtitle' => 'Graph Sub Title',
457              
458             'key' => 0,
459             'key_position' => 'right', # bottom or right
460              
461             'dateadd' => \&dateadd,
462              
463             );
464              
465 3         20 while( my ($key,$value) = each %default ) {
466 102         257 $self->{config}->{$key} = $value;
467             }
468             }
469              
470             # override this so we can pre-manipulate the data
471             sub add_data {
472 2     2 1 2268 my ($self, $conf) = @_;
473              
474             croak 'no data provided'
475 2 50 33     17 unless (defined $conf->{'data'} && ref($conf->{'data'}) eq 'ARRAY');
476              
477             # create an array
478 2 100       8 unless(defined $self->{'data'}) {
479 1         2 my @data;
480 1         3 $self->{'data'} = \@data;
481             }
482              
483             # convert to sorted (by ascending time) array of [ time, value ]
484 2         4 my @new_data = ();
485 2         4 my ($i,$time,@pair);
486              
487 2         5 $i = 0;
488 2         3 while ($i < @{$conf->{'data'}}) {
  8         22  
489 6         11 @pair = ();
490 6 100       19 if (ref($conf->{'data'}->[$i]) eq 'ARRAY') {
491 4         7 push @pair,@{$conf->{'data'}->[$i]};
  4         9  
492 4         5 $i++;
493             }
494             else {
495 2         6 $pair[0] = $conf->{'data'}->[$i++];
496 2         5 $pair[1] = $conf->{'data'}->[$i++];
497             }
498              
499 6         17 $time = str2time($pair[0]);
500             # special case for time-only values
501 6 50 33     808 if ((!defined $time) && ($pair[0] =~ m/^\w*(\d+:\d+:\d+)|(\d+:\d+)\w*$/)) {
502 0         0 $time = str2time('1970-1-1 '.$pair[0]);
503             }
504              
505             croak sprintf("Series %d contains an illegal datetime value %s at sample %d.",
506 6 50       14 scalar(@{$self->{'data'}}),
  0         0  
507             $pair[0],
508             $i / 2)
509             unless (defined $time);
510              
511 6         8 $pair[0] = $time;
512 6         18 push @new_data, [ @pair ];
513             }
514              
515 2         11 my @sorted = sort {@{$a}[0] <=> @{$b}[0]} @new_data;
  5         8  
  5         8  
  5         15  
516              
517             # if stacked, we accumulate the
518 2 50 33     10 if (($self->{config}->{stacked}) && (@{$self->{'data'}})) {
  0         0  
519 0         0 my $prev = $self->{'data'}->[@{$self->{'data'}} - 1]->{pairs};
  0         0  
520              
521             # check our length matches previous
522             croak sprintf("Series %d can not be stacked on previous series. Mismatched length.",
523 0 0       0 scalar(@{$self->{'data'}}))
  0         0  
524             unless (scalar(@sorted) == scalar(@$prev));
525              
526 0         0 for (my $i = 0; $i < @sorted; $i++) {
527             # check the time value matches
528             croak sprintf("Series %d can not be stacked on previous series. Mismatched timestamp at sample %d (time %s).",
529 0 0       0 scalar(@{$self->{'data'}}),
  0         0  
530             $i,
531             HTTP::Date::time2iso($sorted[$i][0]))
532             unless ($sorted[$i][0] == $prev->[$i][0]);
533              
534 0         0 $sorted[$i][1] += $prev->[$i][1];
535             }
536             }
537              
538 2         8 my %store = (
539             'pairs' => \@sorted,
540             );
541              
542 2 50       7 $store{'title'} = $conf->{'title'} if defined $conf->{'title'};
543 2         4 push (@{$self->{'data'}},\%store);
  2         5  
544              
545 2         9 return 1;
546             }
547              
548             #This is for internal use only, so we don't have to pass the time zone around.
549             #It gets local()ed below before it's used.
550             our $_TIME_ZONE;
551              
552             sub burn {
553 2     2 1 2445 my ($self, @args) = @_;
554              
555 2         7 local $_TIME_ZONE = $self->{'config'}{'timescale_time_zone'};
556              
557 2         13 return $self->SUPER::burn(@args);
558             }
559              
560             # Helper function for doing date/time calculations
561             # Current implementations of DateTime can be slow :-(
562             sub dateadd {
563 0     0 0 0 my ($epoch,$value,$unit) = @_;
564 0         0 my $dt = DateTime->from_epoch(epoch => $epoch);
565              
566             #This should have been local()ed prior to getting here.
567 0 0       0 if ( $_TIME_ZONE ) {
568 0         0 $dt->set_time_zone( $_TIME_ZONE );
569             }
570              
571 0         0 $dt->add( $unit => $value );
572 0         0 return $dt->epoch();
573             }
574              
575             # override calculations to set a few calculated values, mainly for scaling
576             sub calculations {
577 1     1 0 3 my $self = shift;
578              
579             # Need to set the timezone in order for x-axis labels to be
580             # formatted correctly by strftime:
581 1         9 $ENV{'TZ'} = $self->{config}->{timescale_time_zone};
582 1         23 POSIX::tzset();
583              
584             # run through the data and calculate maximum and minimum values
585 1         5 my ($max_key_size,$max_time,$min_time,$max_value,$min_value,$max_x_label_length,$x_label);
586              
587 1         3 foreach my $dataset (@{$self->{data}}) {
  1         4  
588 2 100 66     14 $max_key_size = length($dataset->{title}) if ((!defined $max_key_size) || ($max_key_size < length($dataset->{title})));
589              
590 2         3 foreach my $pair (@{$dataset->{pairs}}) {
  2         6  
591 6 50 66     22 $max_time = $pair->[0] if ((!defined $max_time) || ($max_time < $pair->[0]));
592 6 100 66     21 $min_time = $pair->[0] if ((!defined $min_time) || ($min_time > $pair->[0]));
593 6 100 100     37 $max_value = $pair->[1] if (($pair->[1] ne '') && ((!defined $max_value) || ($max_value < $pair->[1])));
      66        
594 6 100 100     26 $min_value = $pair->[1] if (($pair->[1] ne '') && ((!defined $min_value) || ($min_value > $pair->[1])));
      66        
595              
596 6         49 $x_label = strftime($self->{config}->{x_label_format},localtime($pair->[0]));
597 6 100 66     27 $max_x_label_length = length($x_label) if ((!defined $max_x_label_length) || ($max_x_label_length < length($x_label)));
598             }
599             }
600 1         4 $self->{calc}->{max_key_size} = $max_key_size;
601 1         2 $self->{calc}->{max_time} = $max_time;
602 1         4 $self->{calc}->{min_time} = $min_time;
603 1         3 $self->{calc}->{max_value} = $max_value;
604 1         3 $self->{calc}->{min_value} = $min_value;
605 1         2 $self->{calc}->{max_x_label_length} = $max_x_label_length;
606              
607             # Calc the x axis scale values
608 1 50       10 $self->{calc}->{min_timescale_value} = ($self->_is_valid_config('min_timescale_value')) ? str2time($self->{config}->{min_timescale_value}) : $min_time;
609 1 50       4 $self->{calc}->{max_timescale_value} = ($self->_is_valid_config('max_timescale_value')) ? str2time($self->{config}->{max_timescale_value}) : $max_time;
610 1         5 $self->{calc}->{timescale_range} = $self->{calc}->{max_timescale_value} - $self->{calc}->{min_timescale_value};
611              
612             # Calc the y axis scale values
613 1 50       4 $self->{calc}->{min_scale_value} = ($self->_is_valid_config('min_scale_value')) ? $self->{config}->{min_scale_value} : $min_value;
614 1 50       4 $self->{calc}->{max_scale_value} = ($self->_is_valid_config('max_scale_value')) ? $self->{config}->{max_scale_value} : $max_value;
615 1         4 $self->{calc}->{scale_range} = $self->{calc}->{max_scale_value} - $self->{calc}->{min_scale_value};
616              
617 1         2 my ($range,$division,$precision);
618              
619 1 50       4 if ($self->_is_valid_config('scale_divisions')) {
620 0         0 $division = $self->{config}->{scale_divisions};
621              
622 0 0       0 if ($division >= 1) {
623 0         0 $precision = 0;
624             }
625             else {
626 0         0 $precision = length($division) - 2;
627             }
628             }
629             else {
630             # Find divisions, format and range
631 1         8 ($range,$division,$precision) = $self->_range_calc($self->{calc}->{scale_range});
632              
633             # If a max value hasn't been set we can set a revised range and max value
634 1 50       4 if (! $self->_is_valid_config('max_scale_value')) {
635 1         8 $self->{calc}->{max_scale_value} = $self->{calc}->{min_scale_value} + $range;
636 1         4 $self->{calc}->{scale_range} = $self->{calc}->{max_scale_value} - $self->{calc}->{min_scale_value};
637             }
638             }
639 1         4 $self->{calc}->{scale_division} = $division;
640              
641 1 50       4 $self->{calc}->{y_label_format} = ($self->_is_valid_config('y_label_format')) ? $self->{config}->{y_label_format} : "%.${precision}f";
642 1 50       3 $self->{calc}->{data_value_format} = ($self->_is_valid_config('data_value_format')) ? $self->{config}->{data_value_format} : "%.${precision}f";
643             }
644              
645             1;
646             __DATA__
647             <?xml version="1.0"?>
648             <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN"
649             "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
650              
651             [% USE date %]
652             [% stylesheet = 'included' %]
653              
654             [% IF config.style_sheet && config.style_sheet != '' && config.style_sheet.substr(0,7) != 'inline:' %]
655             <?xml-stylesheet href="[% config.style_sheet %]" type="text/css"?>
656             [% ELSIF config.style_sheet && config.style_sheet.substr(0,7) == 'inline:'%]
657             [% stylesheet = 'inline'
658             style_inline = config.style_sheet.substr(7) %]
659             [% ELSE %]
660             [% stylesheet = 'excluded' %]
661             [% END %]
662              
663             <svg width="[% config.width %]" height="[% config.height %]" viewBox="0 0 [% config.width %] [% config.height %]" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
664              
665             <!-- \\\\\\\\\\\\\\\\\\\\\\\\\\\\ -->
666             <!-- Created with SVG::TT::Graph -->
667             <!-- Dave Meibusch -->
668             <!-- //////////////////////////// -->
669              
670             [% IF stylesheet == 'inline' %]
671             [% style_inline %]
672             [% ELSIF stylesheet == 'excluded' %]
673             [%# include default stylesheet if none specified %]
674             <defs>
675             <style type="text/css">
676             <![CDATA[
677             /* Copy from here for external style sheet */
678             .svgBackground{
679             fill:#ffffff;
680             }
681             .graphBackground{
682             fill:#f0f0f0;
683             }
684              
685             /* graphs titles */
686             .mainTitle{
687             text-anchor: middle;
688             fill: #000000;
689             font-size: 14px;
690             font-family: "Arial", sans-serif;
691             font-weight: normal;
692             }
693             .subTitle{
694             text-anchor: middle;
695             fill: #999999;
696             font-size: 12px;
697             font-family: "Arial", sans-serif;
698             font-weight: normal;
699             }
700              
701             .axis{
702             stroke: #000000;
703             stroke-width: 1px;
704             }
705              
706             .guideLines{
707             stroke: #666666;
708             stroke-width: 1px;
709             stroke-dasharray: 5 5;
710             }
711              
712             .xAxisLabels{
713             text-anchor: middle;
714             fill: #000000;
715             font-size: 12px;
716             font-family: "Arial", sans-serif;
717             font-weight: normal;
718             }
719              
720             .yAxisLabels{
721             text-anchor: end;
722             fill: #000000;
723             font-size: 12px;
724             font-family: "Arial", sans-serif;
725             font-weight: normal;
726             }
727              
728             .xAxisTitle{
729             text-anchor: middle;
730             fill: #ff0000;
731             font-size: 14px;
732             font-family: "Arial", sans-serif;
733             font-weight: normal;
734             }
735              
736             .yAxisTitle{
737             fill: #ff0000;
738             text-anchor: middle;
739             font-size: 14px;
740             font-family: "Arial", sans-serif;
741             font-weight: normal;
742             }
743              
744             .dataPointLabel{
745             fill: #000000;
746             text-anchor:middle;
747             font-size: 10px;
748             font-family: "Arial", sans-serif;
749             font-weight: normal;
750             }
751             .staggerGuideLine{
752             fill: none;
753             stroke: #000000;
754             stroke-width: 0.5px;
755             }
756              
757             [% FOREACH dataset = data %]
758             [% color = '' %]
759             [% IF config.random_colors %]
760             [% color = random_color() %]
761             [% ELSE %]
762             [% color = predefined_color(loop.count) %]
763             [% END %]
764              
765             .fill[% loop.count %]{
766             fill: [% color %];
767             fill-opacity: 0.2;
768             stroke: none;
769             }
770              
771             .line[% loop.count %]{
772             fill: none;
773             stroke: [% color %];
774             stroke-width: 1px;
775             }
776              
777             .key[% loop.count %],.fill[% loop.count %]{
778             fill: [% color %];
779             stroke: none;
780             stroke-width: 1px;
781             }
782              
783             [% LAST IF (config.random_colors == 0 && loop.count == 12) %]
784             [% END %]
785              
786             .keyText{
787             fill: #000000;
788             text-anchor:start;
789             font-size: 10px;
790             font-family: "Arial", sans-serif;
791             font-weight: normal;
792             }
793             /* End copy for external style sheet */
794             ]]>
795             </style>
796             </defs>
797             [% END %]
798              
799             [% IF config.key %]
800             <!-- Script to toggle paths when their key is clicked on -->
801             <script language="JavaScript"><![CDATA[
802             function togglePath( series ) {
803             var path = document.getElementById('groupDataSeries' + series);
804             var points = document.getElementById('groupDataLabels' + series);
805             var current = path.getAttribute('opacity');
806             if ( path.getAttribute('opacity') == 0 ) {
807             path.setAttribute('opacity',1);
808             points.setAttribute('opacity',1);
809             } else {
810             path.setAttribute('opacity',0);
811             points.setAttribute('opacity',0);
812             }
813             }
814             ]]></script>
815             [% END %]
816              
817             <!-- svg bg -->
818             <rect x="0" y="0" width="[% config.width %]" height="[% config.height %]" class="svgBackground"/>
819              
820             <!-- ///////////////// CALCULATE GRAPH AREA AND BOUNDARIES //////////////// -->
821             [%# get dimensions of actual graph area (NOT SVG area) %]
822             [% w = config.width %]
823             [% h = config.height %]
824              
825             [%# set start/default coords of graph %]
826             [% x = 0 %]
827             [% y = 0 %]
828              
829             [% char_width = 8 %]
830             [% half_char_height = 2.5 %]
831              
832             <!-- min_value [% calc.min_value %] max_value [% calc.max_value %] min_time [% calc.min_time %] max_time [% calc.max_time %] -->
833              
834             <!-- CALC HEIGHT AND Y COORD DIMENSIONS -->
835             [%# reduce height of graph area if there is labelling on x axis %]
836             [% IF config.show_x_labels %][% h = h - 20 %][% END %]
837              
838             [%# reduce height if x labels are rotated %]
839             [% x_label_allowance = 0 %]
840             [% IF config.rotate_x_labels %]
841             [% x_label_allowance = (calc.max_x_label_length * char_width) - 20 %]
842             [% h = h - x_label_allowance %]
843             [% END %]
844              
845             [%# stagger x labels if overlapping occurs %]
846             [% stagger = 0 %]
847             [% IF config.show_x_labels && config.stagger_x_labels %]
848             [% stagger = 17 %]
849             [% h = h - stagger %]
850             [% END %]
851              
852             [% IF config.show_x_title %][% h = h - 25 - stagger %][% END %]
853              
854             [%# pad top of graph if y axis has data labels so labels do not get chopped off %]
855             [% IF config.show_y_labels %][% h = h - 10 %][% y = y + 10 %][% END %]
856              
857             [%# reduce height if graph has title or subtitle %]
858             [% IF config.show_graph_title %][% h = h - 25 %][% y = y + 25 %][% END %]
859             [% IF config.show_graph_subtitle %][% h = h - 10 %][% y = y + 10 %][% END %]
860              
861             [%# reduce graph dimensions if there is a KEY %]
862             [% key_box_size = 12 %]
863             [% key_padding = 5 %]
864              
865             [% IF config.key && config.key_position == 'right' %]
866             [% w = w - (calc.max_key_size * (char_width - 1)) - (key_box_size * 3 ) %]
867             [% ELSIF config.key && config.key_position == 'bottom' %]
868             [% IF data.size < 4 %]
869             [% h = h - ((data.size + 1) * (key_box_size + key_padding))%]
870             [% ELSE %]
871             [% h = h - (4 * (key_box_size + key_padding))%]
872             [% END %]
873             [% END %]
874              
875             <!-- min_scale_value [% calc.min_scale_value %] max_scale_value [% calc.max_scale_value %] -->
876              
877             [%# base line %]
878             [% base_line = h + y %]
879              
880             [%# find the string length of max value %]
881             [% max_value_length = calc.max_scale_value.length %]
882              
883             [%# label width in pixels %]
884             [% max_value_length_px = max_value_length * char_width %]
885             [%# If the y labels are shown but the size of the x labels are small, pad for y labels %]
886              
887             <!-- CALC WIDTH AND X COORD DIMENSIONS -->
888             [%# reduce width of graph area if there is large labelling on x axis %]
889             [% space_b4_y_axis = (date.format(calc.min_timescale_value,config.x_label_format).length / 2) * char_width %]
890              
891             [% IF config.show_x_labels %]
892             [% IF config.key && config.key_position == 'right' %]
893             [% w = w - space_b4_y_axis %]
894             [% ELSE %]
895             <!-- pad both sides -->
896             [% w = w - (space_b4_y_axis * 2) %]
897             [% END %]
898             [% x = x + space_b4_y_axis %]
899             [% ELSIF config.show_data_values %]
900             [% w = w - (max_value_length_px * 2) %]
901             [% x = x + max_value_length_px %]
902             [% END %]
903              
904             [% IF config.show_y_labels && space_b4_y_axis < max_value_length_px %]
905             <!-- allow slightly more padding if small labels -->
906             [% IF max_value_length < 2 %]
907             [% w = w - (max_value_length * (char_width * 2)) %]
908             [% x = x + (max_value_length * (char_width * 2)) %]
909             [% ELSE %]
910             [% w = w - max_value_length_px %]
911             [% x = x + max_value_length_px %]
912             [% END %]
913             [% ELSIF config.show_y_labels && !config.show_x_labels %]
914             [% w = w - max_value_length_px %]
915             [% x = x + max_value_length_px %]
916             [% END %]
917              
918             [% IF config.show_y_title %]
919             [% w = w - 25 %]
920             [% x = x + 25 %]
921             [% END %]
922              
923             <!-- min_timescale_value [% calc.min_timescale_value %] max_timescale_value [% calc.max_timescale_value %] -->
924              
925             [%# Missing data spans %]
926             [% max_time_span = 0 %]
927             [% IF (matches = config.max_time_span.match('(\d+) ?(\w+)?')) %]
928             [% max_time_span = matches.0 %]
929             [% IF (matches.1) %]
930             [% max_time_span_units = matches.1 %]
931             [% ELSE %]
932             [% max_time_span_units = 'days' %]
933             [% END %]
934             <!-- max_time_span [% max_time_span %] max_time_span_units [% max_time_span_units %]-->
935             [% END %]
936              
937             <!-- ////////////////////////////// BUILD GRAPH AREA ////////////////////////////// -->
938             [%# graph bg and clipping regions for lines/fill and clip extended to included data labels %]
939             <rect x="[% x %]" y="[% y %]" width="[% w %]" height="[% h %]" class="graphBackground"/>
940             <clipPath id="clipGraphArea">
941             <rect x="[% x %]" y="[% y %]" width="[% w %]" height="[% h %]"/>
942             </clipPath>
943              
944             <!-- axis -->
945             <path d="M[% x %] [% base_line %] h[% w %]" class="axis" id="xAxis"/>
946             <path d="M[% x %] [% y %] v[% h %]" class="axis" id="yAxis"/>
947              
948             <!-- ////////////////////////////// AXIS DISTRIBUTIONS //////////////////////////// -->
949             <!-- x axis scaling -->
950             [% dx = calc.timescale_range %]
951             [% IF dx == 0 %]
952             [% dx = 1 %]
953             [% END %]
954             [% dw = w / dx %]
955             <!-- dx [% dx %] dw [% dw %] -->
956              
957             <!-- x axis labels -->
958             [% IF config.show_x_labels %]
959             [% x_value_txt = config.x_label_formatter(date.format(calc.min_timescale_value,config.x_label_format)) %]
960             <text x="[% x %]" y="[% base_line + 15 %]" [% IF config.rotate_x_labels %] transform="rotate(90 [% x - half_char_height %] [% base_line + 15 %]) translate(-10,0)" style="text-anchor: start" [% END %] class="xAxisLabels">[% x_value_txt %]</text>
961             [% last_label = date.format(calc.min_timescale_value,config.x_label_format) %]
962             [% IF config.timescale_divisions %]
963             [% IF (matches = config.timescale_divisions.match('(\d+) ?(\w+)?')) %]
964             [% timescale_division = matches.0 %]
965             [% IF (matches.1) %]
966             [% timescale_division_units = matches.1 %]
967             [% ELSE %]
968             [% timescale_division_units = 'days' %]
969             [% END %]
970             <!-- timescale_division [% timescale_division %] timescale_division_units [% timescale_division_units %]-->
971             [% x_value = config.dateadd(calc.min_timescale_value,timescale_division,timescale_division_units) %]
972             [% count = 0 %]
973             [% WHILE ((x_value > calc.min_timescale_value) && ((x_value < calc.max_timescale_value))) %]
974             [% x_value_txt = config.x_label_formatter(date.format(x_value,config.x_label_format)) %]
975             [% xpos = (dw * (x_value - calc.min_timescale_value)) + x %]
976             [% IF (config.stagger_x_labels && ((count % 2) == 0)) %]
977             <path d="M[% xpos %] [% base_line %] v[% stagger %]" class="staggerGuideLine" />
978             <text x="[% xpos %]" y="[% base_line + 15 + stagger %]" [% IF config.rotate_x_labels %] transform="rotate(90 [% xpos - half_char_height %] [% base_line + 15 + stagger %]) translate(-10,0)" style="text-anchor: start" [% END %] class="xAxisLabels">[% x_value_txt %]</text>
979             [% ELSE %]
980             <text x="[% xpos %]" y="[% base_line + 15 %]" [% IF config.rotate_x_labels %] transform="rotate(90 [% xpos - half_char_height %] [% base_line + 15 %]) translate(-10,0)" style="text-anchor: start" [% END %] class="xAxisLabels">[% x_value_txt %]</text>
981             [% END %]
982             [% last_label = date.format(x_value,config.x_label_format) %]
983             [% x_value = config.dateadd(x_value,timescale_division,timescale_division_units) %]
984             [% count = count + 1 %]
985             [% LAST IF (count >= 999) %]
986             [% END %]
987             [% END %]
988             [% END %]
989             [% IF date.format(calc.max_timescale_value,config.x_label_format) != last_label %]
990             [% x_value_txt = config.x_label_formatter(date.format(calc.max_timescale_value,config.x_label_format)) %]
991             [% IF (config.stagger_x_labels && ((count % 2) == 0)) %]
992             <path d="M[% x + w %] [% base_line %] v[% stagger %]" class="staggerGuideLine" />
993             <text x="[% x + w %]" y="[% base_line + 15 + stagger %]" [% IF config.rotate_x_labels %] transform="rotate(90 [% x + w - half_char_height %] [% base_line + 15 + stagger %]) translate(-10,0)" style="text-anchor: start" [% END %] class="xAxisLabels">[% x_value_txt %]</text>
994             [% ELSE %]
995             <text x="[% x + w %]" y="[% base_line + 15 %]" [% IF config.rotate_x_labels %] transform="rotate(90 [% x + w - half_char_height %] [% base_line + 15 %]) translate(-10,0)" style="text-anchor: start" [% END %] class="xAxisLabels">[% x_value_txt %]</text>
996             [% END %]
997             [% END %]
998             [% END %]
999              
1000             <!-- y axis scaling -->
1001             [%# how much padding between largest bar and top of graph %]
1002             [% top_pad = h / 40 %]
1003              
1004             [% dy = calc.scale_range %]
1005             [% IF dy == 0 %]
1006             [% dy = 1 %]
1007             [% END %]
1008             [% dh = (h - top_pad) / dy %]
1009             <!-- dy [% dy %] dh [% dh %] scale_division [% calc.scale_division %] max_scale_value [% calc.max_scale_value %]-->
1010              
1011             [% count = 0 %]
1012             [% last_label = '' %]
1013             [% IF (calc.min_scale_value > calc.max_scale_value) %]
1014             <!-- Reversed y range -->
1015             [% y_value = calc.max_scale_value %]
1016             [% reversed = 1 %]
1017             [% ELSE %]
1018             [% y_value = calc.min_scale_value %]
1019             [% reversed = 0 %]
1020             [% END %]
1021             [% IF config.show_y_labels %]
1022             [% WHILE ((y_value == calc.min_scale_value) || (y_value == calc.max_scale_value) || ((y_value > calc.min_scale_value) && (y_value < calc.max_scale_value)) || ((y_value > calc.max_scale_value) && (y_value < calc.min_scale_value) && reversed )) %]
1023             [%- next_label = y_value FILTER format(calc.y_label_format) -%]
1024             [%- next_label = config.y_label_formatter(next_label) -%]
1025             [%- IF ((count == 0) && (reversed == 0)) -%]
1026             [%# no stroke for first line unless reversed %]
1027             <text x="[% x - 5 %]" y="[% base_line - (dh * (y_value - calc.min_scale_value)) %]" class="yAxisLabels">[% next_label %]</text>
1028             [%- ELSE -%]
1029             [% IF next_label != last_label %]
1030             <text x="[% x - 5 %]" y="[% base_line - (dh * (y_value - calc.min_scale_value)) %]" class="yAxisLabels">[% next_label %]</text>
1031             <path d="M[% x %] [% base_line - (dh * (y_value - calc.min_scale_value)) %] h[% w %]" class="guideLines"/>
1032             [% END %]
1033             [%- END -%]
1034             [%- y_value = y_value + calc.scale_division -%]
1035             [%- last_label = next_label -%]
1036             [%- count = count + 1 -%]
1037             [%- LAST IF (count >= 999) -%]
1038             [% END %]
1039             [% END %]
1040              
1041             <!-- ////////////////////////////// AXIS TITLES ////////////////////////////// -->
1042             <!-- x axis title -->
1043             [% IF config.show_x_title %]
1044             [% IF !config.show_x_labels %]
1045             [% y_xtitle = 15 %]
1046             [% ELSE %]
1047             [% y_xtitle = 35 %]
1048             [% END %]
1049             <text x="[% (w / 2) + x %]" y="[% h + y + y_xtitle + stagger + x_label_allowance %]" class="xAxisTitle">[% config.x_title %]</text>
1050             [% END %]
1051              
1052             <!-- y axis title -->
1053             [% IF config.show_y_title %]
1054             <text x="10" y="[% (h / 2) + y %]" transform="rotate(270,10,[% (h / 2) + y %])" class="yAxisTitle">[% config.y_title %]</text>
1055             [% END %]
1056              
1057             <!-- ////////////////////////////// SHOW DATA ////////////////////////////// -->
1058             [% line = data.size %]
1059             <g id="groupData" class="data">
1060             [% FOREACH dataset = data.reverse %]
1061             <g id="groupDataSeries[% line %]" class="dataSeries[% line %]" clip-path="url(#clipGraphArea)">
1062             [% IF config.area_fill %]
1063             [%# create alternate fill first (so line can overwrite if necessary) %]
1064             [% xcount = 0 %]
1065             [% FOREACH pair = dataset.pairs %]
1066             [%- IF ((pair.0 >= calc.min_timescale_value) && (pair.0 <= calc.max_timescale_value)) -%]
1067             [%- IF xcount == 0 -%][% lasttime = pair.0 %]<path d="M[% (dw * (pair.0 - calc.min_timescale_value)) + x %] [% base_line %][%- END -%]
1068             [%- IF ((max_time_span) && (pair.0 > config.dateadd(lasttime,max_time_span,max_time_span_units))) -%]
1069             V [% base_line %] H [% (dw * (pair.0 - calc.min_timescale_value)) + x %] V [% base_line - (dh * (pair.1 - calc.min_scale_value)) %]
1070             [%- ELSE -%]
1071             L [% (dw * (pair.0 - calc.min_timescale_value)) + x %] [% base_line - (dh * (pair.1 - calc.min_scale_value)) %]
1072             [%- END -%]
1073             [%- lasttime = pair.0 -%][%- xcount = xcount + 1 -%]
1074             [%- END -%]
1075             [% END %]
1076             [% IF xcount > 0 %] V [% base_line %] Z" class="fill[% line %]"/> [% END %]
1077             [% END %]
1078              
1079             <!--- create line [% dataset.title %]-->
1080             [% xcount = 0 %]
1081             [% FOREACH pair = dataset.pairs %]
1082             [% IF ((pair.0 >= calc.min_timescale_value) && (pair.0 <= calc.max_timescale_value)) %]
1083             [%- IF xcount == 0 -%][%- lasttime = pair.0 -%]<path d="M
1084             [% (dw * (pair.0 - calc.min_timescale_value)) + x %] [% base_line - (dh * (pair.1 - calc.min_scale_value)) %]
1085             [%- ELSE -%]
1086             [%- IF ((max_time_span) && (pair.0 > config.dateadd(lasttime,max_time_span,max_time_span_units))) -%]
1087             M [% (dw * (pair.0 - calc.min_timescale_value)) + x %] [% base_line - (dh * (pair.1 - calc.min_scale_value)) %]
1088             [%- ELSE -%]
1089             L [% (dw * (pair.0 - calc.min_timescale_value)) + x %] [% base_line - (dh * (pair.1 - calc.min_scale_value)) %]
1090             [%- END -%]
1091             [%- END -%]
1092             [%- lasttime = pair.0 -%][%- xcount = xcount + 1 -%]
1093             [%- END -%]
1094             [% END %]
1095             [% IF xcount > 0 %] " class="line[% line %]"/> [% END %]
1096             </g>
1097             <g id="groupDataLabels[% line %]" class="dataLabels[% line %]">
1098             [% IF config.show_data_points || config.show_data_values %]
1099             [% FOREACH pair = dataset.pairs %]
1100             [% IF ((pair.0 >= calc.min_timescale_value) && (pair.0 <= calc.max_timescale_value)) %]
1101             <g class="dataLabel[% line %]" [% IF config.rollover_values %] opacity="0" [% END %]>
1102             [% IF config.show_data_points %]
1103             <circle cx="[% (dw * (pair.0 - calc.min_timescale_value)) + x %]" cy="[% base_line - (dh * (pair.1 - calc.min_scale_value)) %]" r="2.5" class="dataPoint[% line %]"
1104             [% IF config.rollover_values %]
1105             onmouseover="evt.target.parentNode.setAttribute('opacity',1);"
1106             onmouseout="evt.target.parentNode.setAttribute('opacity',0);"
1107             [% END %]
1108             [% IF pair.3.defined %]
1109             onclick="[% pair.3 %]"
1110             [% END %]
1111             ></circle>
1112             [% END %]
1113             [% IF config.show_data_values %]
1114             [%# datavalue shown %]
1115             [% IF (pair.2.defined) && (pair.2 != '') %][% point_label = pair.2 %][% ELSE %][% point_label = pair.1 FILTER format(calc.data_value_format) %][% END %]
1116             <text x="[% (dw * (pair.0 - calc.min_timescale_value)) + x %]" y="[% base_line - (dh * (pair.1 - calc.min_scale_value)) - 6 %]" class="dataPointLabel[% line %]"
1117             [% IF config.rollover_values %]
1118             onmouseover="evt.target.parentNode.setAttribute('opacity',1);"
1119             onmouseout="evt.target.parentNode.setAttribute('opacity',0);"
1120             [% END %]
1121             >[% point_label %]</text>
1122             [% END %]
1123             </g>
1124             [% END %]
1125             [% END %]
1126             [% END %]
1127             </g>
1128             [% line = line - 1 %]
1129             [% END %]
1130             </g>
1131              
1132             <!-- //////////////////////////////////// KEY ////////////////////////////// -->
1133             [% key_count = 1 %]
1134             [% IF config.key && config.key_position == 'right' %]
1135             [% FOREACH dataset = data %]
1136             <rect x="[% x + w + 20 %]" y="[% y + (key_box_size * key_count) + (key_count * key_padding) %]" width="[% key_box_size %]" height="[% key_box_size %]" class="key[% key_count %]" onclick="togglePath([% key_count %]);"/>
1137             <text x="[% x + w + 20 + key_box_size + key_padding %]" y="[% y + (key_box_size * key_count) + (key_count * key_padding) + key_box_size %]" class="keyText">[% dataset.title %]</text>
1138             [% key_count = key_count + 1 %]
1139             [% END %]
1140             [% ELSIF config.key && config.key_position == 'bottom' %]
1141             [%# calc y position of start of key %]
1142             [% y_key = base_line %]
1143             [%# consider x title %]
1144             [% IF config.show_x_title %][% y_key = base_line + 25 %][% END %]
1145             [%# consider x label rotation and stagger %]
1146             [% IF config.rotate_x_labels && config.show_x_labels %]
1147             [% y_key = y_key + x_label_allowance %]
1148             [% ELSIF config.show_x_labels && stagger < 1 %]
1149             [% y_key = y_key + 20 %]
1150             [% END %]
1151              
1152             [% y_key_start = y_key %]
1153             [% x_key = x %]
1154             [% FOREACH dataset = data %]
1155             [% IF key_count == 4 || key_count == 7 || key_count == 10 %]
1156             [%# wrap key every 3 entries %]
1157             [% x_key = x_key + 200 %]
1158             [% y_key = y_key - (key_box_size * 4) - 2 %]
1159             [% END %]
1160             <rect x="[% x_key %]" y="[% y_key + (key_box_size * key_count) + (key_count * key_padding) + stagger %]" width="[% key_box_size %]" height="[% key_box_size %]" class="key[% key_count %]" onclick="togglePath([% key_count %]);"/>
1161             <text x="[% x_key + key_box_size + key_padding %]" y="[% y_key + (key_box_size * key_count) + (key_count * key_padding) + key_box_size + stagger %]" class="keyText">[% dataset.title %]</text>
1162             [% key_count = key_count + 1 %]
1163             [% END %]
1164              
1165             [% END %]
1166              
1167             <!-- //////////////////////////////// MAIN TITLES ////////////////////////// -->
1168             <!-- main graph title -->
1169             [% IF config.show_graph_title %]
1170             <text x="[% config.width / 2 %]" y="15" class="mainTitle">[% config.graph_title %]</text>
1171             [% END %]
1172              
1173             <!-- graph sub title -->
1174             [% IF config.show_graph_subtitle %]
1175             [% IF config.show_graph_title %]
1176             [% y_subtitle = 30 %]
1177             [% ELSE %]
1178             [% y_subtitle = 15 %]
1179             [% END %]
1180             <text x="[% config.width / 2 %]" y="[% y_subtitle %]" class="subTitle">[% config.graph_subtitle %]</text>
1181             [% END %]
1182             </svg>