# The contents of this file are subject to the Mozilla Public # License Version 1.1 (the "License"); you may not use this file # except in compliance with the License. You may obtain a copy of # the License at http://www.mozilla.org/MPL/ # # Software distributed under the License is distributed on an "AS # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or # implied. See the License for the specific language governing # rights and limitations under the License. # # The Original Code is the Bugzilla Bug Tracking System. # # The Initial Developer of the Original Code is Netscape Communications # Corporation. Portions created by Netscape are # Copyright (C) 1998 Netscape Communications Corporation. All # Rights Reserved. # # Contributor(s): Gervase Markham # Albert Ting # A. Karl Kornel use strict; # This module represents a chart. # # Note that it is perfectly legal for the 'lines' member variable of this # class (which is an array of Bugzilla::Series objects) to have empty members # in it. If this is true, the 'labels' array will also have empty members at # the same points. package Bugzilla::Chart; use Bugzilla::Error; use Bugzilla::Util; use Bugzilla::Series; use Date::Format; use Date::Parse; use List::Util qw(max); sub new { my $invocant = shift; my $class = ref($invocant) || $invocant; my ($params) = @_; # Create a ref to an empty hash and bless it my $self = bless {}, $class; if (!$params) { die(__PACKAGE__ . "->new: missing parameters"); } $self->init($params); return $self; } sub init { my $self = shift; my ($params) = @_; # The data structure is a list of lists (lines) of Series objects. # There is a separate list for the labels. # # The URL encoding is: # line0=67&line0=73&line1=81&line2=67... # &label0=B+/+R+/+NEW&label1=... # &select0=1&select3=1... # &cumulate=1&datefrom=2002-02-03&dateto=2002-04-04&ctype=html... # >=1&labelgt=Grand+Total foreach my $param (keys %$params) { # Store all the lines if ($param =~ /^line(\d+)$/) { foreach my $series_id (list $params->{$param}) { detaint_natural($series_id) || ThrowCodeError("invalid_series_id"); my $series = new Bugzilla::Series($series_id); push @{$self->{lines}[$1]}, $series if $series; } } # Store all the labels if ($param =~ /^label(\d+)$/) { $self->{labels}[$1] = $params->{$param}; } } # Store the miscellaneous metadata $self->{cumulate} = $params->{cumulate} ? 1 : 0; $self->{gt} = $params->{gt} ? 1 : 0; $self->{labelgt} = $params->{labelgt}; $self->{datefrom} = $params->{datefrom}; $self->{dateto} = $params->{dateto}; # If we are cumulating, a grand total makes no sense $self->{gt} = 0 if $self->{cumulate}; # Make sure the dates are ones we are able to interpret foreach my $date ('datefrom', 'dateto') { if ($self->{$date}) { $self->{$date} = str2time($self->{$date}) || ThrowUserError("illegal_date", { date => $self->{$date}}); } } # datefrom can't be after dateto if ($self->{datefrom} && $self->{dateto} && $self->{datefrom} > $self->{dateto}) { ThrowUserError("misarranged_dates", { datefrom => $params->{datefrom}, dateto => $params->{dateto}, }); } } # Alter Chart so that the selected series are added to it. sub add { my $self = shift; my @series_ids = @_; # Get the current size of the series; required for adding Grand Total later my $current_size = scalar($self->getSeriesIDs()); # Count the number of added series my $added = 0; # Create new Series and push them on to the list of lines. # Note that new lines have no label; the display template is responsible # for inventing something sensible. foreach my $series_id (@series_ids) { my $series = new Bugzilla::Series($series_id); if ($series) { push @{$self->{lines}}, [ $series ]; push @{$self->{labels}}, ""; $added++; } } # If we are going from < 2 to >= 2 series, add the Grand Total line. if (!$self->{gt} && $current_size < 2 && $current_size+$added >= 2) { $self->{gt} = 1; } } # Alter Chart so that the selections are removed from it. sub remove { my $self = shift; my @line_ids = @_; foreach my $line_id (@line_ids) { if ($line_id == 65536) { # Magic value - delete Grand Total. $self->{gt} = 0; } else { delete($self->{lines}->[$line_id]); delete($self->{labels}->[$line_id]); } } } # Alter Chart so that the selections are summed. sub sum { my $self = shift; my @line_ids = @_; # We can't add the Grand Total to things. @line_ids = grep !/^65536$/, @line_ids; # We can't add less than two things. return if scalar(@line_ids) < 2; my @series; my $label = ""; my $biggestlength = 0; # We rescue the Series objects of all the series involved in the sum. foreach my $line_id (@line_ids) { my @line = @{$self->{lines}->[$line_id]}; foreach my $series (@line) { push(@series, $series); } # We keep the label that labels the line with the most series. if (scalar(@line) > $biggestlength) { $biggestlength = scalar(@line); $label = $self->{labels}->[$line_id]; } } $self->remove(@line_ids); push(@{$self->{lines}}, \@series); push(@{$self->{labels}}, $label); } sub data { my $self = shift; $self->{_data} ||= $self->readData(); return $self->{_data}; } # Convert the Chart's data into a plottable form in $self->{_data}. sub readData { my $self = shift; my @data; my @maxvals; # Note: you get a bad image if getSeriesIDs returns nothing # We need to handle errors better. my $series_ids = join(",", $self->getSeriesIDs()); return [] unless $series_ids; # Work out the date boundaries for our data. my $dbh = Bugzilla->dbh; # The date used is the one given if it's in a sensible range; otherwise, # it's the earliest or latest date in the database as appropriate. my $datefrom = $dbh->selectrow_array( "SELECT MIN(series_date) FROM series_data WHERE series_id IN ($series_ids)" ); $datefrom = str2time($datefrom); if ($self->{datefrom} && $self->{datefrom} > $datefrom) { $datefrom = $self->{datefrom}; } my $dateto = $dbh->selectrow_array( "SELECT MAX(series_date) FROM series_data WHERE series_id IN ($series_ids)" ); $dateto = str2time($dateto); if ($self->{dateto} && $self->{dateto} < $dateto) { $dateto = $self->{dateto}; } # Convert UNIX times back to a date format usable for SQL queries. my $sql_from = time2str('%Y-%m-%d', $datefrom); my $sql_to = time2str('%Y-%m-%d', $dateto); # Prepare the query which retrieves the data for each series my $query = "SELECT " . $dbh->sql_to_days('series_date') . " - " . $dbh->sql_to_days('?') . ", series_value" . " FROM series_data WHERE series_id = ? AND series_date >= ?"; if ($dateto) { $query .= " AND series_date <= ?"; } my $sth = $dbh->prepare($query); my $gt_index = $self->{gt} ? scalar @{$self->{lines}} : undef; my $line_index = 0; $maxvals[$gt_index] = 0 if $gt_index; my @datediff_total; foreach my $line (@{$self->{lines}}) { # Even if we end up with no data, we need an empty arrayref to prevent # errors in the PNG-generating code $data[$line_index] = []; $maxvals[$line_index] = 0; foreach my $series (@$line) { # Get the data for this series and add it on if ($dateto) { $sth->execute($sql_from, $series->{series_id}, $sql_from, $sql_to); } else { $sth->execute($sql_from, $series->{series_id}, $sql_from); } my $points = $sth->fetchall_arrayref(); foreach my $point (@$points) { my ($datediff, $value) = @$point; $data[$line_index][$datediff] ||= 0; $data[$line_index][$datediff] += $value; if ($data[$line_index][$datediff] > $maxvals[$line_index]) { $maxvals[$line_index] = $data[$line_index][$datediff]; } $datediff_total[$datediff] += $value; # Add to the grand total, if we are doing that if ($gt_index) { $data[$gt_index][$datediff] ||= 0; $data[$gt_index][$datediff] += $value; if ($data[$gt_index][$datediff] > $maxvals[$gt_index]) { $maxvals[$gt_index] = $data[$gt_index][$datediff]; } } } } # We are done with the series making up this line, go to the next one $line_index++; } # calculate maximum y value if ($self->{cumulate}) { # Make sure we do not try to take the max of an array with undef values my @processed_datediff; while (@datediff_total) { my $datediff = shift @datediff_total; push @processed_datediff, $datediff if defined($datediff); } $self->{y_max_value} = max(@processed_datediff); } else { $self->{y_max_value} = max(@maxvals); } $self->{y_max_value} |= 1; # For log() # Align the max y value: # For one- or two-digit numbers, increase y_max_value until divisible by 8 # For larger numbers, see the comments below to figure out what's going on if ($self->{y_max_value} < 100) { do { ++$self->{y_max_value}; } while ($self->{y_max_value} % 8 != 0); } else { # First, get the # of digits in the y_max_value my $num_digits = 1+int(log($self->{y_max_value})/log(10)); # We want to zero out all but the top 2 digits my $mask_length = $num_digits - 2; $self->{y_max_value} /= 10**$mask_length; $self->{y_max_value} = int($self->{y_max_value}); $self->{y_max_value} *= 10**$mask_length; # Add 10^$mask_length to the max value # Continue to increase until it's divisible by 8 * 10^($mask_length-1) # (Throwing in the -1 keeps at least the smallest digit at zero) do { $self->{y_max_value} += 10**$mask_length; } while ($self->{y_max_value} % (8*(10**($mask_length-1))) != 0); } # Add the x-axis labels into the data structure my $date_progression = generateDateProgression($datefrom, $dateto); unshift @data, $date_progression; if ($self->{gt}) { # Add Grand Total to label list push @{$self->{labels}}, $self->{labelgt}; $data[$gt_index] ||= []; } return \@data; } # Flatten the data structure into a list of series_ids sub getSeriesIDs { my $self = shift; my @series_ids; foreach my $line (@{$self->{lines}}) { foreach my $series (@$line) { push @series_ids, $series->{series_id}; } } return @series_ids; } # Class method to get the data necessary to populate the "select series" # widgets on various pages. sub getVisibleSeries { my %cats; my $grouplist = Bugzilla->user->groups_as_string; # Get all visible series my $dbh = Bugzilla->dbh; my $serieses = $dbh->selectall_arrayref( "SELECT cc1.name, cc2.name, series.name, series.series_id FROM series". " INNER JOIN series_categories AS cc1 ON series.category = cc1.id" . " INNER JOIN series_categories AS cc2 ON series.subcategory = cc2.id" . " LEFT JOIN category_group_map AS cgm ON series.category = cgm.category_id" . " AND cgm.group_id NOT IN ($grouplist)" . " WHERE creator = ? OR (is_public = 1 AND cgm.category_id IS NULL)" . " GROUP BY series.series_id, cc1.name, cc2.name, series.name", undef, Bugzilla->user->id ); foreach my $series (@$serieses) { my ($cat, $subcat, $name, $series_id) = @$series; $cats{$cat}{$subcat}{$name} = $series_id; } return \%cats; } sub generateDateProgression { my ($datefrom, $dateto) = @_; my @progression; $dateto = $dateto || time(); my $oneday = 60 * 60 * 24; # When the from and to dates are converted by str2time(), you end up with # a time figure representing midnight at the beginning of that day. We # adjust the times by 1/3 and 2/3 of a day respectively to prevent # edge conditions in time2str(). $datefrom += $oneday / 3; $dateto += (2 * $oneday) / 3; while ($datefrom < $dateto) { push @progression, time2str("%Y-%m-%d", $datefrom); $datefrom += $oneday; } return \@progression; } sub dump { my $self = shift; # Make sure we've read in our data my $data = $self->data; require Data::Dumper; print "
Bugzilla::Chart object:\n";
    print html_quote(Data::Dumper::Dumper($self));
    print "
"; } 1;