From 84a9d726c57187dbb67cea3f7b5dc9b31e8b7c4d Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Wed, 28 Jan 2026 20:19:02 -0500 Subject: [PATCH 1/5] The initial code for the beginning of the StatisticalPlots macro. This contains the methods add_barplot, add_histogram, add_boxplot and add_scatterplot. There are many options for each and there is documentation as well. This also includes the add_rectangle method to the plot.pl macro which is a wrapper for the add_dataset for creating rectangles. --- lib/Plots/Plot.pm | 12 + lib/Plots/StatPlot.pm | 38 +++ macros/core/PGbasicmacros.pl | 2 +- macros/graph/StatisticalPlots.pl | 507 +++++++++++++++++++++++++++++++ macros/graph/plots.pl | 25 +- 5 files changed, 579 insertions(+), 5 deletions(-) create mode 100644 lib/Plots/StatPlot.pm create mode 100644 macros/graph/StatisticalPlots.pl diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm index b03262739..b7dc07e6c 100644 --- a/lib/Plots/Plot.pm +++ b/lib/Plots/Plot.pm @@ -392,6 +392,18 @@ sub add_arc { return $self->_add_arc(@data); } +sub add_rectangle { + my ($self, $pt0, $pt2, %options) = @_; + + Value::Error('The first point must be an array ref of length 2') + unless ref($pt0) eq 'ARRAY' && scalar(@$pt0) == 2; + Value::Error('The second point must be an array ref of length 2') + unless ref($pt2) eq 'ARRAY' && scalar(@$pt2) == 2; + my $pt1 = [ $pt2->[0], $pt0->[1] ]; + my $pt3 = [ $pt0->[0], $pt2->[1] ]; + return $self->add_dataset($pt0, $pt1, $pt2, $pt3, $pt0, %options); +} + sub add_vectorfield { my ($self, @options) = @_; my $data = Plots::Data->new(name => 'vectorfield'); diff --git a/lib/Plots/StatPlot.pm b/lib/Plots/StatPlot.pm new file mode 100644 index 000000000..d211fd9e2 --- /dev/null +++ b/lib/Plots/StatPlot.pm @@ -0,0 +1,38 @@ + +=head1 DESCRIPTION + +This is the main C code for creating statistical plots. + +See L for more details. +=cut + +package Plots::StatPlot; + +use strict; +use warnings; + +use WeBWorK::Utils qw(min max); + +sub new { + my ($class, %options) = @_; + return Plots::Plot->new(%options); +} + +sub add_histogram { + my ($self, $data, %opts) = @_; + + my %options = ( + bins => 10, + %opts + ); + + my $min = min(@$data); + my $max = max(@$data); + my $bin_size = ($max - $min) / $options{bins}; + + my @counts; + $counts[ int(($_ - $min) / $bin_size) ]++ for (@$data); + +} + +1; diff --git a/macros/core/PGbasicmacros.pl b/macros/core/PGbasicmacros.pl index 6437d71e9..d1a44db70 100644 --- a/macros/core/PGbasicmacros.pl +++ b/macros/core/PGbasicmacros.pl @@ -2923,7 +2923,7 @@ sub image { ); next; } - if (ref $image_item eq 'Plots::Plot') { + if (ref $image_item eq 'Plots::Plot' || ref $image_item eq 'Plots::StatPlot') { # Update image attributes as needed. $image_item->{width} = $width if $out_options{width}; $image_item->{height} = $height if $out_options{height}; diff --git a/macros/graph/StatisticalPlots.pl b/macros/graph/StatisticalPlots.pl new file mode 100644 index 000000000..5f3ec1ac0 --- /dev/null +++ b/macros/graph/StatisticalPlots.pl @@ -0,0 +1,507 @@ + +=head1 NAME + +StatisticalPlots.pl - A macro to create dynamic statistics plots to include in PG problems. + +=head1 DESCRIPTION + +This macro includes a number of methods to include statistical plots in PG problems. +This is based on L which will draw in either C or C format with the +default for the former to be used for hardcopy and the latter for HTML output. + +The statistical plot available are + +=over + +=item Box Plots + +=item Bar Plots + +=item Histograms + +=item Scatter Plots + +=back + +=head2 USAGE + +First, start with a C object as in + + loadMacros('StatisticsPlots.pl'); + $stat_plot = StatPlot( + xmin => -1, + xmax => 8, + ymin => -1.5, + ymax => 10, + xtick_delta => 1, + ytick_delta => 4, + aria_label => 'Bar plot of a set of data' + ); + +The options for C are identical to that of a C object and all options are in the +L. Note that each of the x- and y-axes have separate options and +each option is preceded with a C or C. + +After the C is created then specific plots are added to the axes. For example: + + @y = (3, 6, 7, 8, 4, 1); + $hist->add_barplot( + [ 1 .. 6 ], ~~@y, + fill_color => 'yellow', + width => 1, + bar_width => 0.9 + ); + +will add a barplot to the axes with heights in the C<@y> variable at the x-locations C<(1..6)>. + +See below for more details about creating a barplot and its options. + +=head1 PLOT ELEMENTS + +As mentioned above, a statistical plot is a set of axes with one or more plot objects such as +bar plots, box plots or scatter plots. A C must be created first and then one or more +of the following can be added. + +=head2 BAR PLOTS + +A bar plot is added with the C method to a C. The general form for a +bar plot with vertical bars (the default) is + + $stat_plot->add_barplot($xdata, $ydata, %opts); + +where C<$xdata> is an ARRAYREF of x-values where the bars will be centered and C<$ydata> is an +ARRAY of heights of the bars. Note: if the option C<< orientation => 'horizontal' >> is included +then the bar lengths are the values in C<$xdata> and locations in C<$ydata>. + +The options for the C method are two fold. The following are specific to changing +the barplot, and the rest are passed along to C, which is a wrapper function for +C. + +The C option can take on C (default) or C to make vertical +or horizontal bars. Above was an example with vertical bars and an example with horizontal bars is + + @x = (3, 6, 7, 8, 4, 1); + $hist->add_barplot( + ~~@x, [ 1 .. 6 ], + orientation => 'horizontal', + fill_color => 'yellow', + width => 1, + bar_width => 0.9 + ); + +The option C is a number in the range [0,1] to give the relative width of the bar. If +C<< bar_width => 1 >> (default), then there is no gap between bars. In the example above, with +C<< bar_width => 0.9 >>, there is a small gap between bars. + +Any remaining options are passed to C which has the same options as C, +however, if C is passed to C, then the C<< fill => 'self' >> is also +passed along. + +See L for specifics about other options to both changing fill and stroke +color. + +=head2 HISTOGRAMS + +A L is added with the `add_histogram` method to a C. The general form +is + + $stat_plot->add_histogram($data, %options); + +where C<$data> is an array ref of univariate data. The C<%options> include both options +for the histogram like number of bins as well as options for the bars. + +An example is performed using the C function from C which +produces normally distributed random variables. + + macros('StatisticalPlots.pl', 'PGstatisticsmacros.pl'); + @data = urand(30, 9, 50, 6); # create 50 random variables with mean 30 and std. dev of 9. + $stat_plot = StatPlot( + xmin => 0, + xmax => 65, + ymin => 0, + ymax => 12, + xtick_delta => 10, + ytick_delta => 2 + ); + $stat_plot->add_histogram( + ~~@data, + min => 10, + max => 60, + bins => 10, + fill_color => 'lightgreen', + width => 1 + ); + +The first argument to C is an array ref of univariate data. + +=head3 Options + +The following are options specific to histograms. + +=over + +=item min + +The left edge of the leftmost box. If not defined, the minimum of C<$data> is used. + +=item max + +The right edge of the rightmost box. If not defined, the maximum of C<$data> is used. + +=item bins + +The number of bins/boxes to use for the histogram. This must be an integer greater +than 0. If not defined, the default value of 10 is used. + +=item normalize + +If the value of 0 (default) is used, the height of the bars is the count of the number +of points. If the value is 1, then the heights are scaled so the total height of the +bars is 1. + +=back + +The rest of the options are passed through to the C method in which the +fill color and opacity as well as the stroke color and width. See both L +and L for more details. + +=head2 BOX PLOTS + +A box plot (also called a box and whiskers plot) can be created with the C method. If one performs + + $stat_plot->add_boxplot($data, %options); + +or if one has multiple box plots + + $stat_plot->add_boxplot([$data1, $data2, ...], %options); + +where C<$data> is an array ref of univariate data or a hash ref of the boxplot characteristics, +then a box plot is created using the five number summary (minimum, first quartile, median, +third quartile, maximum) of the data. These values are calculated using the C +function from C. An example of creating a boxplot with an arrayref of +univariate data is + + @data = urand(100,25,75,6); + + $boxplot = StatPlot( + xmin => 0, + xmax => 200, + xtick_delta => 25, + show_grid => 0, + ymin => -5, + ymax => 25, + yvisible => 0, + aspect_ratio => 4, + rounded_corners => 1 + ); + + $boxplot->add_boxplot(~~@data, fill_color => 'lightblue', width => 1); + +and as with other methods in this macro, one can pass options to the characteristic of the +box plot (like fill color or stroke color and width) within the C method. + +If C<$data> is a hashref, it must contains the fields C that are used to +define the boxplot. Optionally, one may also include the field C which is an array ref of values +which will be plotted beyond the whiskers. + +An example of this is + + $params = { + min => random(150, 175, 5), + q1 => random(180, 225, 5), + median => random(250, 275, 5), + q3 => random(280, 320, 10), + max => random(325, 350, 5), + outliers => [115,130] + }; + + $boxplot = StatPlot( + xmin => 100, + xmax => 400, + xtick_delta => 50, + show_grid => 0, + ymin => -5, + ymax => 25, + yvisible => 0, + aspect_ratio => 4 + ); + + $boxplot->add_boxplot($params); + +=head3 Options + +The following are options to the C method. + +=over + +=item orientation + +This is the direction of the box plot and can take on values 'horizontal' (default) +or 'vertical'. + +=item box_center + +The location of the center of the box. This is optional and if not defined will center the +box between the axis and the edge of the plot. + +If multiple box plots are included, this option will be created to equally space the +box plots between the axis and the edge of the plot. If included, this option must be an +arrayref of values (in the x-direction for vertical plots and y-direction for horizontal). + + box_center => [3,6,9] + +as an example. + +=item box_width + +The width of the box in the direction perpendicular to the orientation. If not define, it +will take the value of 0.5 times the space between the axis and the edge of the plot. + +If multiple box plots are defined, this should only be a single value. + +=back + +As with other methods in the macro, other options can be passed along to C +and C which are used in the macro. + +Also, if C is included, then C<< fill => 'self' >> is automatically added on the +box. + +=head2 SCATTER PLOTS + +To produce a scatter plot, use the C method to a C. The general +form is + + $plot->add_scatterplot($data, %options); + +where the dataset in C<$data> is an array ref of C pairs as an array ref. For example, + + $stat_plot = StatPlot( + xmin => -1, + xmax => 15, + xtick_delta => 5, + ymin => -1, + ymax => 15, + ytick_delta => 5, + ); + + $data = [ [1,1], [2,3], [3,4], [5,5], [7,8], [10,9], [12,10]]; + + $stat_plot->add_scatterplot($data, marks => 'diamond', mark_size => 5, color => 'orange'); + +This method is simply a wrapper for the C method where the defaults are different. Specifically + +=over + +=item linestyle + +The C option is set to 'none', so that lines are not drawn between the points. + +=item marks + +The C is default to 'circle'. See L for other mark options. + +=item mark_size + +The C is default to 3. + +=back + +If more that one dataset is to be plotted, simply call the C method multiple +times. This can be done with a single C method call, but this wrapper makes it +easier to set different options + +=cut + +BEGIN { strict->import; } + +sub _StatisticalPlots_init { + main::PG_restricted_eval('sub StatPlot { Plots::StatPlot->new(@_); }'); +} + +loadMacros('PGstatisticsmacros.pl'); + +package Plots::StatPlot; +our @ISA = qw(Plots::Plot); + +sub new { + my $self = shift; + my $class = ref($self) || $self; + + return $class->SUPER::new(@_); +} + +sub add_histogram { + my ($self, $data, %opts) = @_; + + my %options = ( + bins => 10, + %opts + ); + + Value::Error("The option 'bins' must be a positive integer") + unless $options{bins} =~ /^\d+$/ && $options{bins} > 0; + + # if the bin_width is 0, set the num_bins to 1 and give a non-zero bin_width. + + my @counts; + my $min = $options{min} // main::min(@$data); + my $max = $options{max} // main::max(@$data); + my $bin_width = ($max - $min) / $options{bins}; + + $counts[ int(($_ - $min) / $bin_width) ]++ for (@$data); + my @xdata = map { $min + (0.5 + $_) * $bin_width } (0 .. $#counts); + + # Remove these options and pass the rest to add_barplot + delete $options{$_} for ('min', 'max', 'bins'); + + if ($options{orientation} eq 'vertical') { + $self->add_barplot(\@xdata, \@counts, %options); + } else { + $self->add_barplot(\@counts, \@xdata, %options); + } + + return \@counts; +} + +# Create a barplot where for each x in xdata, create a bar of height y in ydata. + +sub add_barplot { + my ($self, $xdata, $ydata, %opts) = @_; + + my %options = ( + bar_width => 1, + orientation => 'vertical', + %opts + ); + + Value::Error('The lengths of the data in the first two arguments must be arrayrefs of the same length') + unless ref $xdata eq 'ARRAY' && ref $xdata eq 'ARRAY' && scalar(@$xdata) == scalar(@$ydata); + + # assume that the $xdata is equally spaced. TODO: should we handle arbitrary spaced bars? + my $bar_width = $options{orientation} eq 'vertical' ? $xdata->[1] - $xdata->[0] : $ydata->[1] - $ydata->[0]; + + # if fill_color is passed as an option, set the 'fill' to 'self'. + $options{fill} = 'self' if $options{fill_color}; + + for my $j (0 .. scalar(@$xdata) - 1) { + if ($options{orientation} eq 'vertical') { + $self->SUPER::add_rectangle([ $xdata->[$j] - 0.5 * $bar_width * $options{bar_width}, 0 ], + [ $xdata->[$j] + 0.5 * $bar_width * $options{bar_width}, $ydata->[$j] ], %options); + } else { + $self->SUPER::add_rectangle([ 0, $ydata->[$j] - 0.5 * $bar_width * $options{bar_width} ], + [ $xdata->[$j], $ydata->[$j] + 0.5 * $bar_width * $options{bar_width} ], %options); + } + } +} + +sub add_boxplot { + my ($self, $data, %opts) = @_; + + my %options = ( + orientation => 'horizontal', + %opts + ); + + # Placeholder for boxplot implementation. + if (ref $data eq 'ARRAY' && (ref $data->[0] eq 'ARRAY' || ref $data->[0] eq 'HASH')) { + my ($box_centers, $box_width); + if ($options{box_center}) { + Value::Error( + "The option 'box_center' must be an array ref with the same length as the box plots to produce.") + unless ref $options{box_center} eq 'ARRAY' && scalar(@{ $options{box_center} }) == scalar(@$data); + $box_centers = $options{box_center}; + delete $options{box_center}; + } else { + my $n = scalar(@$data); + unless ($options{box_width}) { + $options{box_width} = + ($options{orientation} eq 'vertical' ? $self->axes->xaxis('max') : $self->axes->yaxis('max')) / + (2.5 * $n); + } + $box_centers = [ map { 2 * $options{box_width} * $_ } (1 .. $n + 1) ]; + } + for (0 .. $#$data) { + $options{box_center} = $box_centers->[$_]; + $self->_add_boxplot($data->[$_], %options); + } + + } else { + $self->_add_boxplot($data, %options); + } +} + +sub _add_boxplot { + my ($self, $data, %options) = @_; + + my $orientation = $options{orientation} // 'horizontal'; + my $params; + if (ref $data eq 'ARRAY') { + my @five_point = main::five_point_summary(@$data); + $params = { + min => $five_point[0], + q1 => $five_point[1], + median => $five_point[2], + q3 => $five_point[3], + max => $five_point[4] + }; + } elsif (ref $data eq 'HASH') { + # check that all aspects of the boxplot are passed in. + my %count; + $count{$_}++ for ('min', 'q1', 'median', 'q3', 'max'); + $count{$_}-- for (keys %$data); + for (keys %count) { + # warn "$_: $count{$_}"; + Value::Error("The parameter $_ is missing from the boxplot attributes.") if $count{$_} > 0; + } + $params = $data; + } + # warn "$_: $options{$_}" for (keys %options);s + + # if fill_color is passed as an option, set the 'fill' to 'self'. + $options{fill} = 'self' if $options{fill_color}; + + if ($orientation eq 'horizontal') { + my $box_center = $options{box_center} // 0.5 * $self->axes->yaxis->{max}; + my $box_width = $options{box_width} // 0.5 * $self->axes->yaxis->{max}; + + $self->add_rectangle([ $params->{q1}, $box_center - 0.5 * $box_width ], + [ $params->{q3}, $box_center + 0.5 * $box_width ], %options); + $self->add_dataset([ $params->{min}, $box_center ], [ $params->{q1}, $box_center ], %options); + $self->add_dataset([ $params->{q3}, $box_center ], [ $params->{max}, $box_center ], %options); + $self->add_dataset([ $params->{median}, $box_center - 0.5 * $box_width ], + [ $params->{median}, $box_center + 0.5 * $box_width ], %options); + + if ($params->{outliers}) { + my @points = map { [ $_, $box_center ] } @{ $params->{outliers} }; + $self->add_dataset(@points, linestyle => 'none', marks => 'plus', marksize => 3); + } + } elsif ($orientation eq 'vertical') { + + my $box_center = $options{box_center} // 0.5 * $self->axes->xaxis->{max}; + my $box_width = $options{box_width} // 0.5 * $self->axes->xaxis->{max}; + + $self->add_rectangle([ $box_center - 0.5 * $box_width, $params->{q1} ], + [ $box_center + 0.5 * $box_width, $params->{q3} ], %options); + $self->add_dataset([ $box_center, $params->{min} ], [ $box_center, $params->{q1} ], %options); + $self->add_dataset([ $box_center, $params->{q3} ], [ $box_center, $params->{max}, ], %options); + $self->add_dataset([ $box_center - 0.5 * $box_width, $params->{median} ], + [ $box_center + 0.5 * $box_width, $params->{median} ], %options); + } +} + +sub add_scatterplot { + my ($self, $data, %opts) = @_; + + my %options = ( + linestyle => 'none', + marks => 'circle', + mark_size => 3, + %opts + ); + + $self->add_dataset(@$data, %options); + +} + +1; diff --git a/macros/graph/plots.pl b/macros/graph/plots.pl index de245bd66..a60bc8d13 100644 --- a/macros/graph/plots.pl +++ b/macros/graph/plots.pl @@ -16,7 +16,7 @@ =head1 DESCRIPTION =head1 USAGE -First create a Plots object: +First create a Plot object: loadMacros('plots.pl'); $plot = Plot( @@ -291,13 +291,30 @@ =head2 PLOT ARCS the direction of the third point. Arcs always go in the counter clockwise direction. - $plot->add_arc([$start_x, $start_y], [$center_x, $center_y], [$end_x, $end_y], %options); + $plot->add_arc([$center_x, $center_y], [$start_x, $start_y], [$end_x, $end_y], %options); $plot->add_arc( [[$center_x1, $center_y1], [$start_x1, $start_y1], [$end_x1, $end_y1], %options1], [[$center_x2, $center_y2], [$start_x2, $start_y2], [$end_x2, $end_y2], %options2], ... ); +=head2 PLOT RECTANGLES + +A rectangle can be plotted with the C<< $plot->add_rectangle >> method. This is a +convenience method as a shortcut for the C<< $plot->add_dataset >> method. The first +two arguments are opposite corners of the rectangle. All other arguments are passed +as options to the C<< add_dataset >> method. + +The following makes a filled rectangle with a thicker blue border. + + $plot->add_rectangle([2,1], [6,3], + color => 'blue', + width => 1.5, + fill => 'self', + fill_color => 'yellow', + fill_opacity => 0.1, + ); + =head2 PLOT VECTOR FIELDS Vector fields and slope fields can be plotted using the C<< $plot->add_vectorfield >> method. @@ -579,7 +596,7 @@ =head2 DATASET OPTIONS =item tikz_options -Additional pgfplots C<\addplot> options to be appeneded to the tikz output. +Additional pgfplots C<\addplot> options to be appended to the tikz output. =back @@ -696,7 +713,7 @@ =head2 STAMPS # Add a single stamp. $plot->add_stamp($x1, $y1, symbol => $symbol, color => $color, radius => $radius); - # Add Multple stamps. + # Add Multiple stamps. $plot->add_stamp( [$x1, $y1, symbol => $symbol1, color => $color1, radius => $radius1], [$x2, $y2, symbol => $symbol2, color => $color2, radius => $radius2], From 97465b5c33b2a7be0c28dac0144abb7d2b024861 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Sat, 31 Jan 2026 08:16:47 -0500 Subject: [PATCH 2/5] Add normalize functionality to add_histogram. Cleanup of the POD. Removal of Plots::StatsPlot->new function. It wasn't needed. Make sure Plots::StatsPlot objects are rendered in PGbasicmacros.pl. --- lib/Plots/Plot.pm | 4 +-- lib/Plots/StatPlot.pm | 38 ---------------------- macros/core/PGbasicmacros.pl | 5 +-- macros/graph/StatisticalPlots.pl | 56 +++++++++++++++++++------------- macros/graph/plots.pl | 2 +- 5 files changed, 37 insertions(+), 68 deletions(-) delete mode 100644 lib/Plots/StatPlot.pm diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm index b7dc07e6c..6481c9586 100644 --- a/lib/Plots/Plot.pm +++ b/lib/Plots/Plot.pm @@ -399,9 +399,7 @@ sub add_rectangle { unless ref($pt0) eq 'ARRAY' && scalar(@$pt0) == 2; Value::Error('The second point must be an array ref of length 2') unless ref($pt2) eq 'ARRAY' && scalar(@$pt2) == 2; - my $pt1 = [ $pt2->[0], $pt0->[1] ]; - my $pt3 = [ $pt0->[0], $pt2->[1] ]; - return $self->add_dataset($pt0, $pt1, $pt2, $pt3, $pt0, %options); + return $self->add_dataset($pt0, [ $pt2->[0], $pt0->[1] ], $pt2, [ $pt0->[0], $pt2->[1] ], $pt0, %options); } sub add_vectorfield { diff --git a/lib/Plots/StatPlot.pm b/lib/Plots/StatPlot.pm deleted file mode 100644 index d211fd9e2..000000000 --- a/lib/Plots/StatPlot.pm +++ /dev/null @@ -1,38 +0,0 @@ - -=head1 DESCRIPTION - -This is the main C code for creating statistical plots. - -See L for more details. -=cut - -package Plots::StatPlot; - -use strict; -use warnings; - -use WeBWorK::Utils qw(min max); - -sub new { - my ($class, %options) = @_; - return Plots::Plot->new(%options); -} - -sub add_histogram { - my ($self, $data, %opts) = @_; - - my %options = ( - bins => 10, - %opts - ); - - my $min = min(@$data); - my $max = max(@$data); - my $bin_size = ($max - $min) / $options{bins}; - - my @counts; - $counts[ int(($_ - $min) / $bin_size) ]++ for (@$data); - -} - -1; diff --git a/macros/core/PGbasicmacros.pl b/macros/core/PGbasicmacros.pl index d1a44db70..6ea5862b6 100644 --- a/macros/core/PGbasicmacros.pl +++ b/macros/core/PGbasicmacros.pl @@ -2942,10 +2942,7 @@ sub image { $width_ratio = 0.001 * $image_item->{tex_size}; } $image_item = insertGraph($image_item) - if (ref $image_item eq 'WWPlot' - || ref $image_item eq 'Plots::Plot' - || ref $image_item eq 'PGlateximage' - || ref $image_item eq 'PGtikz'); + if (grep { ref $image_item eq $_ } ('WWPlot', 'Plots::Plot', 'Plots::StatPlot', 'PGlateximage', 'PGtikz')); my $imageURL = alias($image_item) // ''; $imageURL = ($envir{use_site_prefix}) ? $envir{use_site_prefix} . $imageURL : $imageURL; my $id = $main::PG->getUniqueName('img'); diff --git a/macros/graph/StatisticalPlots.pl b/macros/graph/StatisticalPlots.pl index 5f3ec1ac0..89b78b1cb 100644 --- a/macros/graph/StatisticalPlots.pl +++ b/macros/graph/StatisticalPlots.pl @@ -9,7 +9,7 @@ =head1 DESCRIPTION This is based on L which will draw in either C or C format with the default for the former to be used for hardcopy and the latter for HTML output. -The statistical plot available are +The statistical plots available are =over @@ -73,10 +73,16 @@ =head2 BAR PLOTS ARRAY of heights of the bars. Note: if the option C<< orientation => 'horizontal' >> is included then the bar lengths are the values in C<$xdata> and locations in C<$ydata>. +=head3 OPTIONS + The options for the C method are two fold. The following are specific to changing the barplot, and the rest are passed along to C, which is a wrapper function for C. +=over + +=item orientation + The C option can take on C (default) or C to make vertical or horizontal bars. Above was an example with vertical bars and an example with horizontal bars is @@ -89,21 +95,25 @@ =head2 BAR PLOTS bar_width => 0.9 ); +=item bar_width + The option C is a number in the range [0,1] to give the relative width of the bar. If C<< bar_width => 1 >> (default), then there is no gap between bars. In the example above, with C<< bar_width => 0.9 >>, there is a small gap between bars. +=back + Any remaining options are passed to C which has the same options as C, however, if C is passed to C, then the C<< fill => 'self' >> is also passed along. -See L for specifics about other options to both changing fill and stroke -color. +See L for specifics about other options to +both changing fill and stroke color. =head2 HISTOGRAMS -A L is added with the `add_histogram` method to a C. The general form -is +A L is added with the `add_histogram` method +to a C. The general form is $stat_plot->add_histogram($data, %options); @@ -167,7 +177,8 @@ =head3 Options =head2 BOX PLOTS -A box plot (also called a box and whiskers plot) can be created with the C method. If one performs +A box plot (also called a box and whiskers plot) can be created with the C method. +If one performs $stat_plot->add_boxplot($data, %options); @@ -201,8 +212,8 @@ =head2 BOX PLOTS box plot (like fill color or stroke color and width) within the C method. If C<$data> is a hashref, it must contains the fields C that are used to -define the boxplot. Optionally, one may also include the field C which is an array ref of values -which will be plotted beyond the whiskers. +define the boxplot. Optionally, one may also include the field C which is an array +ref of values which will be plotted beyond the whiskers. An example of this is @@ -289,7 +300,7 @@ =head2 SCATTER PLOTS $stat_plot->add_scatterplot($data, marks => 'diamond', mark_size => 5, color => 'orange'); -This method is simply a wrapper for the C method where the defaults are different. Specifically +This method is simply a wrapper for the C method where the defaults are different. =over @@ -299,7 +310,8 @@ =head2 SCATTER PLOTS =item marks -The C is default to 'circle'. See L for other mark options. +The C is default to 'circle'. See L +for other mark options. =item mark_size @@ -324,36 +336,36 @@ sub _StatisticalPlots_init { package Plots::StatPlot; our @ISA = qw(Plots::Plot); -sub new { - my $self = shift; - my $class = ref($self) || $self; - - return $class->SUPER::new(@_); -} - sub add_histogram { my ($self, $data, %opts) = @_; my %options = ( - bins => 10, + bins => 10, + normalize => 0, + orientation => 'vertical', %opts ); Value::Error("The option 'bins' must be a positive integer") unless $options{bins} =~ /^\d+$/ && $options{bins} > 0; - # if the bin_width is 0, set the num_bins to 1 and give a non-zero bin_width. - - my @counts; + my @counts = (0) x $options{bins}; my $min = $options{min} // main::min(@$data); my $max = $options{max} // main::max(@$data); my $bin_width = ($max - $min) / $options{bins}; + # TODO: if the bin_width is 0, set the num_bins to 1 and give a non-zero bin_width. + $counts[ int(($_ - $min) / $bin_width) ]++ for (@$data); + if ($options{normalize}) { + my $total = 0; + $total += $_ for (@counts); + @counts = map { $_ / $total } @counts; + } my @xdata = map { $min + (0.5 + $_) * $bin_width } (0 .. $#counts); # Remove these options and pass the rest to add_barplot - delete $options{$_} for ('min', 'max', 'bins'); + delete $options{$_} for ('min', 'max', 'bins', 'normalize'); if ($options{orientation} eq 'vertical') { $self->add_barplot(\@xdata, \@counts, %options); diff --git a/macros/graph/plots.pl b/macros/graph/plots.pl index a60bc8d13..241759806 100644 --- a/macros/graph/plots.pl +++ b/macros/graph/plots.pl @@ -5,7 +5,7 @@ =head1 NAME =head1 DESCRIPTION -This macro creates a Plots object that is used to add data of different +This macro creates a Plot object that is used to add data of different elements of a 2D plot, then draw the plot. The plots can be drawn using different formats. Currently C (using PGFplots) and C graphics format are available. The default is to use C for HTML output and C for From 16538fabf8a35cfbb8fca554e44d54f30394077c Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Mon, 2 Feb 2026 17:34:29 -0500 Subject: [PATCH 3/5] Adds some other functionality to StatisticalPlots.pl - Ability to change the outlier marks in a boxplot - Adds a whisker cap option to a boxplot. - Adds a cap_width option for these whiskers. - Adds custom tick labels for the x-axis. --- htdocs/js/Plots/plots.js | 10 ++++++- lib/Plots/Axes.pm | 1 + lib/Plots/JSXGraph.pm | 28 +++++++++++++------- lib/Plots/Plot.pm | 2 ++ macros/graph/StatisticalPlots.pl | 45 +++++++++++++++++++++++++++++--- 5 files changed, 71 insertions(+), 15 deletions(-) diff --git a/htdocs/js/Plots/plots.js b/htdocs/js/Plots/plots.js index 7e1c0416a..d7f4b4456 100644 --- a/htdocs/js/Plots/plots.js +++ b/htdocs/js/Plots/plots.js @@ -375,9 +375,17 @@ const PGplots = { options.xAxis.overrideOptions ?? {} ) )); - xAxis.defaultTicks.generateLabelText = plot.generateLabelText; + xAxis.defaultTicks.formatLabelText = plot.formatLabelText; + if (options.xAxis.ticks?.customLabels) { + xAxis.defaultTicks.generateLabelText = function (tick) { + return options.xAxis.ticks.customLabels[tick.usrCoords[1]/options.xAxis.ticks.distance-1]; + } + } else { + xAxis.defaultTicks.generateLabelText = plot.generateLabelText; + } + if (options.xAxis.location !== 'middle' && options.xAxis.name !== '') { plot.xLabel = board.create( 'text', diff --git a/lib/Plots/Axes.pm b/lib/Plots/Axes.pm index 5141a255e..990908f7a 100644 --- a/lib/Plots/Axes.pm +++ b/lib/Plots/Axes.pm @@ -313,6 +313,7 @@ sub axis_defaults { tick_labels => 1, tick_label_format => 'decimal', tick_label_digits => 2, + tick_label_custom => undef, # NEW: Array of custom labels tick_distance => 0, tick_scale => 1, tick_scale_symbol => '', diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index cc65c8d42..d81deb2cb 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -122,21 +122,29 @@ sub HTML { $options->{mathJaxTickLabels} = $axes->style('mathjax_tick_labels') if $xvisible || $yvisible; if ($xvisible) { - $options->{xAxis}{name} = $axes->xaxis('label'); - $options->{xAxis}{ticks}{show} = $axes->xaxis('show_ticks'); - $options->{xAxis}{ticks}{labels} = $axes->xaxis('tick_labels'); - $options->{xAxis}{ticks}{labelFormat} = $axes->xaxis('tick_label_format'); - $options->{xAxis}{ticks}{labelDigits} = $axes->xaxis('tick_label_digits'); + $options->{xAxis}{name} = $axes->xaxis('label'); + $options->{xAxis}{ticks}{show} = $axes->xaxis('show_ticks'); + $options->{xAxis}{ticks}{labels} = $axes->xaxis('tick_labels'); + if ($axes->xaxis('tick_label_custom')) { + $options->{xAxis}{ticks}{customLabels} = $axes->xaxis('tick_label_custom'); + } else { + $options->{xAxis}{ticks}{labelFormat} = $axes->xaxis('tick_label_format'); + $options->{xAxis}{ticks}{labelDigits} = $axes->xaxis('tick_label_digits'); + } $options->{xAxis}{ticks}{scaleSymbol} = $axes->xaxis('tick_scale_symbol'); $options->{xAxis}{arrowsBoth} = $axes->xaxis('arrows_both'); $options->{xAxis}{overrideOptions} = $axes->xaxis('jsx_options') if $axes->xaxis('jsx_options'); } if ($yvisible) { - $options->{yAxis}{name} = $axes->yaxis('label'); - $options->{yAxis}{ticks}{show} = $axes->yaxis('show_ticks'); - $options->{yAxis}{ticks}{labels} = $axes->yaxis('tick_labels'); - $options->{yAxis}{ticks}{labelFormat} = $axes->yaxis('tick_label_format'); - $options->{yAxis}{ticks}{labelDigits} = $axes->yaxis('tick_label_digits'); + $options->{yAxis}{name} = $axes->yaxis('label'); + $options->{yAxis}{ticks}{show} = $axes->yaxis('show_ticks'); + $options->{yAxis}{ticks}{labels} = $axes->yaxis('tick_labels'); + if ($axes->yaxis('tick_label_custom')) { + $options->{yAxis}{ticks}{customLabels} = $axes->yaxis('tick_label_custom'); + } else { + $options->{yAxis}{ticks}{labelFormat} = $axes->yaxis('tick_label_format'); + $options->{yAxis}{ticks}{labelDigits} = $axes->yaxis('tick_label_digits'); + } $options->{yAxis}{ticks}{scaleSymbol} = $axes->yaxis('tick_scale_symbol'); $options->{yAxis}{arrowsBoth} = $axes->yaxis('arrows_both'); $options->{yAxis}{overrideOptions} = $axes->yaxis('jsx_options') if $axes->yaxis('jsx_options'); diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm index 6481c9586..40b94bdaf 100644 --- a/lib/Plots/Plot.pm +++ b/lib/Plots/Plot.pm @@ -399,6 +399,8 @@ sub add_rectangle { unless ref($pt0) eq 'ARRAY' && scalar(@$pt0) == 2; Value::Error('The second point must be an array ref of length 2') unless ref($pt2) eq 'ARRAY' && scalar(@$pt2) == 2; + # If the fill_color option is set, set the fill to 'self'. + $options{fill} = 'self' if $options{fill_color} && !defined($options{fill}); return $self->add_dataset($pt0, [ $pt2->[0], $pt0->[1] ], $pt2, [ $pt0->[0], $pt2->[1] ], $pt0, %options); } diff --git a/macros/graph/StatisticalPlots.pl b/macros/graph/StatisticalPlots.pl index 89b78b1cb..146c2f7ef 100644 --- a/macros/graph/StatisticalPlots.pl +++ b/macros/graph/StatisticalPlots.pl @@ -270,6 +270,21 @@ =head3 Options If multiple box plots are defined, this should only be a single value. +=item whisker_cap + +Value of 0 (default) or 1. If 1, his will add a short line perpendicular to the whiskers +on the boxplot with relative size C + +=item cap_width + +The width of the cap as a fraction of the box height (if C<< orientation => 'vertical' >>) +or box width (if C<< orientation => 'horizontal' >>). Default value is 0.2. + +=item outlier_mark + +The shape of the mark to use for outliers. Default is 'plus'. See L +for other mark options. + =back As with other methods in the macro, other options can be passed along to C @@ -411,7 +426,10 @@ sub add_boxplot { my ($self, $data, %opts) = @_; my %options = ( - orientation => 'horizontal', + orientation => 'horizontal', + whisker_cap => 0, + cap_width => 0.2, + outlier_mark => 'plus', %opts ); @@ -463,12 +481,10 @@ sub _add_boxplot { $count{$_}++ for ('min', 'q1', 'median', 'q3', 'max'); $count{$_}-- for (keys %$data); for (keys %count) { - # warn "$_: $count{$_}"; Value::Error("The parameter $_ is missing from the boxplot attributes.") if $count{$_} > 0; } $params = $data; } - # warn "$_: $options{$_}" for (keys %options);s # if fill_color is passed as an option, set the 'fill' to 'self'. $options{fill} = 'self' if $options{fill_color}; @@ -484,9 +500,17 @@ sub _add_boxplot { $self->add_dataset([ $params->{median}, $box_center - 0.5 * $box_width ], [ $params->{median}, $box_center + 0.5 * $box_width ], %options); + # add whisker caps + if ($options{whisker_cap}) { + $self->add_dataset([ $params->{max}, $box_center - 0.5 * $options{cap_width} * $box_width ], + [ $params->{max}, $box_center + 0.5 * $options{cap_width} * $box_width ], %options); + $self->add_dataset([ $params->{min}, $box_center - 0.5 * $options{cap_width} * $box_width ], + [ $params->{min}, $box_center + 0.5 * $options{cap_width} * $box_width ], %options); + } + if ($params->{outliers}) { my @points = map { [ $_, $box_center ] } @{ $params->{outliers} }; - $self->add_dataset(@points, linestyle => 'none', marks => 'plus', marksize => 3); + $self->add_dataset(@points, linestyle => 'none', marks => $options{outlier_mark}, marksize => 3); } } elsif ($orientation eq 'vertical') { @@ -499,6 +523,19 @@ sub _add_boxplot { $self->add_dataset([ $box_center, $params->{q3} ], [ $box_center, $params->{max}, ], %options); $self->add_dataset([ $box_center - 0.5 * $box_width, $params->{median} ], [ $box_center + 0.5 * $box_width, $params->{median} ], %options); + + if ($params->{outliers}) { + my @points = map { [ $box_center, $_ ] } @{ $params->{outliers} }; + $self->add_dataset(@points, linestyle => 'none', marks => $options{outlier_mark}, marksize => 3); + } + + # add whisker caps + if ($options{whisker_cap}) { + $self->add_dataset([ $box_center - 0.5 * $options{cap_width} * $box_width, $params->{max} ], + [ $box_center + 0.5 * $options{cap_width} * $box_width, $params->{max}, ], %options); + $self->add_dataset([ $box_center - 0.5 * $options{cap_width} * $box_width, $params->{min} ], + [ $box_center + 0.5 * $options{cap_width} * $box_width, $params->{min} ], %options); + } } } From 5fd4d249440258cf37ff07ad377839ee64841040 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Fri, 6 Feb 2026 10:53:38 -0500 Subject: [PATCH 4/5] Add pie chart. Add beginnings of some color palettes. Fix a typo in Data.pm --- htdocs/js/Plots/plots.js | 6 +- lib/Plots/Data.pm | 4 +- macros/graph/StatisticalPlots.pl | 239 +++++++++++++++++++++++++++++-- 3 files changed, 233 insertions(+), 16 deletions(-) diff --git a/htdocs/js/Plots/plots.js b/htdocs/js/Plots/plots.js index d7f4b4456..837e5fbe4 100644 --- a/htdocs/js/Plots/plots.js +++ b/htdocs/js/Plots/plots.js @@ -375,13 +375,13 @@ const PGplots = { options.xAxis.overrideOptions ?? {} ) )); - + xAxis.defaultTicks.formatLabelText = plot.formatLabelText; if (options.xAxis.ticks?.customLabels) { xAxis.defaultTicks.generateLabelText = function (tick) { - return options.xAxis.ticks.customLabels[tick.usrCoords[1]/options.xAxis.ticks.distance-1]; - } + return options.xAxis.ticks.customLabels[tick.usrCoords[1] / options.xAxis.ticks.distance - 1]; + }; } else { xAxis.defaultTicks.generateLabelText = plot.generateLabelText; } diff --git a/lib/Plots/Data.pm b/lib/Plots/Data.pm index cdd1840d5..d40afdc50 100644 --- a/lib/Plots/Data.pm +++ b/lib/Plots/Data.pm @@ -66,7 +66,7 @@ stored in the C<< $data->{function} >> hash, though other data is stored as a st ); Note, the first argument must be $self->context when called from C -to use a single context for all C objects. +to use a single context for all C objects. This is also used to set a two variable function (used for slope or vector fields): @@ -116,7 +116,7 @@ Takes a MathObject C<$formula> and replaces the function with either a JavaScript or PGF function string. If the function contains any function tokens not supported, a warning and empty string is returned. - $formula The mathobject formula object, either $self->{function}{Fx} or $self->{function}{Fy}. + $formula The MathObject formula object, either $self->{function}{Fx} or $self->{function}{Fy}. $type 'js' or 'PGF' (falls back to js for any input except 'PGF'). $xvar The x-variable name, $self->{function}{xvar}. $yvar The y-variable name, $self->{function}{yvar}, for vector fields. diff --git a/macros/graph/StatisticalPlots.pl b/macros/graph/StatisticalPlots.pl index 146c2f7ef..253d45159 100644 --- a/macros/graph/StatisticalPlots.pl +++ b/macros/graph/StatisticalPlots.pl @@ -21,6 +21,8 @@ =head1 DESCRIPTION =item Scatter Plots +=item Pie Charts + =back =head2 USAGE @@ -69,7 +71,7 @@ =head2 BAR PLOTS $stat_plot->add_barplot($xdata, $ydata, %opts); -where C<$xdata> is an ARRAYREF of x-values where the bars will be centered and C<$ydata> is an +where C<$xdata> is an array reference of x-values where the bars will be centered and C<$ydata> is an ARRAY of heights of the bars. Note: if the option C<< orientation => 'horizontal' >> is included then the bar lengths are the values in C<$xdata> and locations in C<$ydata>. @@ -169,9 +171,19 @@ =head3 Options of points. If the value is 1, then the heights are scaled so the total height of the bars is 1. +=item stroke_color + +This sets the color of the boundary of the rectangle and the whiskers. It is an alias for +the C option of L. See L for options to change the color. + +=item stroke_width + +This sets the width of the boundary of the rectangle and the whiskers. This is an alias for +the C option of L. + =back -The rest of the options are passed through to the C method in which the +The rest of the options are passed through to the L method in which the fill color and opacity as well as the stroke color and width. See both L and L for more details. @@ -189,8 +201,8 @@ =head2 BOX PLOTS where C<$data> is an array ref of univariate data or a hash ref of the boxplot characteristics, then a box plot is created using the five number summary (minimum, first quartile, median, third quartile, maximum) of the data. These values are calculated using the C -function from C. An example of creating a boxplot with an arrayref of -univariate data is +function from C. An example of creating a boxplot with an array reference +of univariate data is @data = urand(100,25,75,6); @@ -211,7 +223,7 @@ =head2 BOX PLOTS and as with other methods in this macro, one can pass options to the characteristic of the box plot (like fill color or stroke color and width) within the C method. -If C<$data> is a hashref, it must contains the fields C that are used to +If C<$data> is a hash reference, it must contains the fields C that are used to define the boxplot. Optionally, one may also include the field C which is an array ref of values which will be plotted beyond the whiskers. @@ -257,7 +269,7 @@ =head3 Options If multiple box plots are included, this option will be created to equally space the box plots between the axis and the edge of the plot. If included, this option must be an -arrayref of values (in the x-direction for vertical plots and y-direction for horizontal). +array reference of values (in the x-direction for vertical plots and y-direction for horizontal). box_center => [3,6,9] @@ -285,9 +297,19 @@ =head3 Options The shape of the mark to use for outliers. Default is 'plus'. See L for other mark options. +=item stroke_color + +This sets the color of the boundary of the rectangle and the whiskers. It is an alias for +the C option of L. See L for options to change the color. + +=item stroke_width + +This sets the width of the boundary of the rectangle and the whiskers. This is an alias for +the C option of L. + =back -As with other methods in the macro, other options can be passed along to C +As with other methods in the macro, other options can be passed along to L and C which are used in the macro. Also, if C is included, then C<< fill => 'self' >> is automatically added on the @@ -332,12 +354,108 @@ =head2 SCATTER PLOTS The C is default to 3. +=item mark_color + +This changes the mark color and is an alias for the C option. See L +for options to change the color. + =back If more that one dataset is to be plotted, simply call the C method multiple times. This can be done with a single C method call, but this wrapper makes it easier to set different options +=head2 PIE CHARTS + +A pie chart is a circle that divided in to sectors whose size is proportional to an input array. +The sectors are generally given each a color and a label. This method will also produce +donut charts (or ring charts), which is a pie chart with a hole. + +The general form is + + $stat_plot->add_piechart($data, %options); + +where $data is an array reference of values. + +The following are the options: + +=over + +=item center + +The center of the circle as an array reference. The default value is C<[0,0]>. + +=item radius + +The radius of the circle. The default value of C<4> is chosen to fit nicely with the +default values of the bounding box of the C which ranges from -5 to 5 +in both the x- and y-directions. + +=item inner_radius + +If you desire a donut chart or ring chart, set this to a value less than the radius. +The default value is 0. + +=item angle_offset + +The first sector by default starts at angle 0 (from the positive horizontal axis) in degrees. Use +this to change this. + +=item color_palette + +This is either the name of a color palette or an array reference of colors for each of the +sectors in the pie chart. If the length of this array reference is smaller than +the C<$data> array reference, then the colors will be cycled. The default is to +use the 'default' color palette. See L for more information. + +=item color_sectors + +If this is 1 (default), then colors are used for the pie chart. If 0, then the +sectors are not filled. See C for selecting colors. + +=item sector_labels + +The labels for the sector as a array reference of strings or values. The default is for +no labels. If this is used, the length of this must be the same as the C<$data> array +reference. + +=back + +=head2 COLOR PALETTES + +The color palettes for the bar plots and pie charts can be select from the C +function. This allows a number of built-in/generated color palettes. To get an +array reference of either named or generated colors: + + color_palette($name, num_colors => $n); + +For example, + + color_palette('rainbow'); + +returns the 6 colors of the rainbow. Some of the palettes have fixed numbers of colors, +whereas others have variable numbers. If C is not defined, then some palettes +return a fixed number (like 'rainbow') and if the C is needed, then the +default of 10 is assumed. + +=head3 PALETTE NAMES + +=over + +=item rainbow + +The colors of the rainbow from violet to red. The C options is ignored. + +=item random + +This will return C random colors from the defined SVG colors. + +=back + +=head2 LEGENDS + +A legend is helpful for some plots. + =cut BEGIN { strict->import; } @@ -399,10 +517,10 @@ sub add_barplot { my %options = ( bar_width => 1, orientation => 'vertical', - %opts + plot_option_aliases(%opts) ); - Value::Error('The lengths of the data in the first two arguments must be arrayrefs of the same length') + Value::Error('The lengths of the data in the first two arguments must be array references of the same length') unless ref $xdata eq 'ARRAY' && ref $xdata eq 'ARRAY' && scalar(@$xdata) == scalar(@$ydata); # assume that the $xdata is equally spaced. TODO: should we handle arbitrary spaced bars? @@ -430,7 +548,7 @@ sub add_boxplot { whisker_cap => 0, cap_width => 0.2, outlier_mark => 'plus', - %opts + plot_option_aliases(%opts) ); # Placeholder for boxplot implementation. @@ -546,11 +664,110 @@ sub add_scatterplot { linestyle => 'none', marks => 'circle', mark_size => 3, - %opts + plot_option_aliases(%opts) ); $self->add_dataset(@$data, %options); } +sub add_piechart { + my ($self, $data, %opts) = @_; + + my %options = ( + center => [ 0, 0 ], + radius => 4, + angle_offset => 0, + inner_radius => 0, + plot_option_aliases(%opts) + ); + + Value::Error('The number of labels must equal the number of sectors in the pie chart') + unless defined($options{labels}) && scalar(@$data) == scalar(@{ $options{labels} }); + + my $fill_colors = + (!defined $options{fill_colors} || ref $options{fill_colors} ne 'ARRAY') + ? color_palette($options{fill_colors}) + : $options{fill_colors}; + + my $pi = 4 * atan2(1, 1); + my $total = 0; + $total += $_ for (@$data); + + my $theta = $options{angle_offset} * $pi / 180; # first angle of the sector + for (0 .. $#$data) { + my $delta_theta = 2 * $pi * $data->[$_] / $total; + $self->add_multipath( + [ + [ + "$options{center}->[0] + $options{radius} * cos(t)", + "$options{center}->[1] + $options{radius} * sin(t)", + $theta, + $theta + $delta_theta + ], + [ + "$options{center}->[0] + $options{inner_radius} * cos(t)", + "$options{center}->[1] + $options{inner_radius} * sin(t)", + $theta + $delta_theta, + $theta + ], + ], + 't', + cycle => 1, + fill => 'self', + fill_color => $fill_colors->[ $_ % scalar(@$fill_colors) ], + %options + ); + # add the labels if defined + if ($options{labels}) { + my $alpha = $theta + 0.5 * $delta_theta; + # take $alpha mod 2pi + $alpha = $alpha - (2 * $pi * int($alpha / (2 * $pi))); + + $self->add_label( + 1.1 * $options{radius} * cos($alpha), + 1.1 * $options{radius} * sin($alpha), + $options{labels}->[$_], + (0 <= $alpha && $alpha < $pi / 4) + || (7 * $pi / 4 < $alpha && $alpha < 2 * $pi) ? (h_align => 'left') + : $pi / 4 <= $alpha < 3 * $pi / 4 ? (v_align => 'bottom') + : 3 * $pi / 4 <= $alpha < 5 * $pi / 4 ? (h_align => 'right') + : (v_align => 'top') + ); + } + $theta += $delta_theta; + } + +} + +# This provides some alias for options. +# For additional aliases, add to the %aliases hash below. + +sub plot_option_aliases { + my (%options) = @_; + + my %aliases = ( + width => 'stroke_width', + color => 'stroke_color', + color => 'mark_color' + ); + + for (keys %aliases) { + $options{$_} = $options{ $aliases{$_} } if $options{ $aliases{$_} }; + delete $options{ $aliases{$_} }; + } + return %options; +} + +sub color_palette { + my ($palette_name, $num_colors) = @_; + + $palette_name = 'rainbow' unless defined($palette_name); + + if ($palette_name eq 'rainbow') { + return [ 'violet', 'blue', 'green', 'yellow', 'orange', 'red' ]; + } + +} + 1; From e3a37225e0cb055839750fd536adbd3e02506b45 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Tue, 24 Feb 2026 15:16:09 -0500 Subject: [PATCH 5/5] Adding custom tick labels and making sure it works in tikz as well. Added additional palettes. Improvement for documentation. --- htdocs/js/Plots/plots.js | 15 +- lib/Plots/Axes.pm | 53 +++++--- lib/Plots/JSXGraph.pm | 8 +- lib/Plots/Tikz.pm | 37 ++++- macros/graph/StatisticalPlots.pl | 227 ++++++++++++++++++++++--------- 5 files changed, 246 insertions(+), 94 deletions(-) diff --git a/htdocs/js/Plots/plots.js b/htdocs/js/Plots/plots.js index 837e5fbe4..71e536129 100644 --- a/htdocs/js/Plots/plots.js +++ b/htdocs/js/Plots/plots.js @@ -378,6 +378,8 @@ const PGplots = { xAxis.defaultTicks.formatLabelText = plot.formatLabelText; + // If there are custom labels defined, create a function to generate them at the + // tick locations. if (options.xAxis.ticks?.customLabels) { xAxis.defaultTicks.generateLabelText = function (tick) { return options.xAxis.ticks.customLabels[tick.usrCoords[1] / options.xAxis.ticks.distance - 1]; @@ -482,8 +484,19 @@ const PGplots = { options.yAxis.overrideOptions ?? {} ) )); - yAxis.defaultTicks.generateLabelText = plot.generateLabelText; + yAxis.defaultTicks.formatLabelText = plot.formatLabelText; + // If there are custom labels defined, create a function to generate them at the + // tick locations. + if (options.yAxis.ticks?.customLabels) { + console.log(options.yAxis); + yAxis.defaultTicks.generateLabelText = function (tick) { + console.log(tick); + return options.yAxis.ticks.customLabels[tick.usrCoords[2] / options.yAxis.ticks.distance - 1]; + }; + } else { + yAxis.defaultTicks.generateLabelText = plot.generateLabelText; + } if (options.yAxis.location !== 'center' && options.yAxis.name !== '') { plot.yLabel = board.create('text', [0, 0, options.yAxis.name ?? '\\(y\\)'], { diff --git a/lib/Plots/Axes.pm b/lib/Plots/Axes.pm index 990908f7a..e621faa73 100644 --- a/lib/Plots/Axes.pm +++ b/lib/Plots/Axes.pm @@ -63,7 +63,7 @@ It is also possible to get multiple options for both axes using the get method, a reference to a hash of requested keys, such as: $bounds = $plot->axes->get('xmin', 'xmax', 'ymin', 'ymax'); - # The following is equivlant to $plot->axes->grid + # The following is equivalent to $plot->axes->grid $grid = $plot->axes->get('xmajor', 'xminor', 'xtick_delta', 'ymajor', 'yminor', 'ytick_delta'); It is also possible to get the bounds as an array in the order xmin, ymin, xmax, ymax @@ -103,6 +103,17 @@ difference between the C and C divided by the C. Default: 0 This can be either 1 (show) or 0 (don't show) the labels for the major ticks. Default: 1 +=item tick_custom_labels + +If defined, this replaces the automatically generated labels for the ticks. +This should be an array reference with the number of labels equal to the +expected number of ticks. If this is defined then the C is +ignored. + +Example: + + xtick_custom_labels => ['A', 'B', 'C', 'D'] + =item tick_label_format This can be one of "decimal", "fraction", "multiple", or "scinot". If this is @@ -304,26 +315,26 @@ sub new { sub axis_defaults { my ($self, $axis) = @_; return ( - visible => 1, - min => -5, - max => 5, - label => $axis eq 'y' ? '\(y\)' : '\(x\)', - location => $axis eq 'y' ? 'center' : 'middle', - position => 0, - tick_labels => 1, - tick_label_format => 'decimal', - tick_label_digits => 2, - tick_label_custom => undef, # NEW: Array of custom labels - tick_distance => 0, - tick_scale => 1, - tick_scale_symbol => '', - show_ticks => 1, - tick_delta => 0, - tick_num => 5, - major => 1, - minor => 3, - minor_grids => 1, - arrows_both => 0, + visible => 1, + min => -5, + max => 5, + label => $axis eq 'y' ? '\(y\)' : '\(x\)', + location => $axis eq 'y' ? 'center' : 'middle', + position => 0, + tick_labels => 1, + tick_label_format => 'decimal', + tick_label_digits => 2, + tick_custom_labels => undef, + tick_distance => 0, + tick_scale => 1, + tick_scale_symbol => '', + show_ticks => 1, + tick_delta => 0, + tick_num => 5, + major => 1, + minor => 3, + minor_grids => 1, + arrows_both => 0, ); } diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index d81deb2cb..b6dc6cc4c 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -125,8 +125,8 @@ sub HTML { $options->{xAxis}{name} = $axes->xaxis('label'); $options->{xAxis}{ticks}{show} = $axes->xaxis('show_ticks'); $options->{xAxis}{ticks}{labels} = $axes->xaxis('tick_labels'); - if ($axes->xaxis('tick_label_custom')) { - $options->{xAxis}{ticks}{customLabels} = $axes->xaxis('tick_label_custom'); + if ($axes->xaxis('tick_custom_labels')) { + $options->{xAxis}{ticks}{customLabels} = $axes->xaxis('tick_custom_labels'); } else { $options->{xAxis}{ticks}{labelFormat} = $axes->xaxis('tick_label_format'); $options->{xAxis}{ticks}{labelDigits} = $axes->xaxis('tick_label_digits'); @@ -139,8 +139,8 @@ sub HTML { $options->{yAxis}{name} = $axes->yaxis('label'); $options->{yAxis}{ticks}{show} = $axes->yaxis('show_ticks'); $options->{yAxis}{ticks}{labels} = $axes->yaxis('tick_labels'); - if ($axes->yaxis('tick_label_custom')) { - $options->{yAxis}{ticks}{customLabels} = $axes->yaxis('tick_label_custom'); + if ($axes->yaxis('tick_custom_labels')) { + $options->{yAxis}{ticks}{customLabels} = $axes->yaxis('tick_custom_labels'); } else { $options->{yAxis}{ticks}{labelFormat} = $axes->yaxis('tick_label_format'); $options->{yAxis}{ticks}{labelDigits} = $axes->yaxis('tick_label_digits'); diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 611273f29..169b64769 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -213,11 +213,19 @@ sub generate_axes { my $x_tick_distance = $axes->xaxis('tick_distance'); my $x_tick_scale = $axes->xaxis('tick_scale') || 1; + # If there are custom labels and 0 is one of the ticks, an empty label needs to be added. + my @custom_xlabels; + @custom_xlabels = @{ $axes->xaxis('tick_custom_labels') } if $axes->xaxis('tick_custom_labels'); + my @xticks = grep { $_ > $xmin && $_ < $xmax } map { -$_ * $x_tick_distance * $x_tick_scale } reverse(1 .. -$xmin / ($x_tick_distance * $x_tick_scale)); - push(@xticks, 0) if $xmin < 0 && $xmax > 0; + if ($xmin < 0 && $xmax > 0) { + push(@xticks, 0); + unshift(@custom_xlabels, '') if @custom_xlabels; + } + push(@xticks, grep { $_ > $xmin && $_ < $xmax } map { $_ * $x_tick_distance * $x_tick_scale } (1 .. $xmax / ($x_tick_distance * $x_tick_scale))); @@ -226,8 +234,13 @@ sub generate_axes { $xvisible && $axes->xaxis('show_ticks') && $axes->xaxis('tick_labels') - ? (",\nxticklabel shift=9pt,\nxticklabel style={anchor=center},\nxticklabels={" - . join(',', map { $self->formatTickLabelText($_ / $x_tick_scale, 'xaxis') } @xticks) . '}') + ? ( + $axes->xaxis('tick_custom_labels') + ? (",\nxticklabel shift=9pt,\nxticklabel style={anchor=center},\nxticklabels={" + . join(',', @custom_xlabels) . '}') + : (",\nxticklabel shift=9pt,\nxticklabel style={anchor=center},\nxticklabels={" + . join(',', map { $self->formatTickLabelText($_ / $x_tick_scale, 'xaxis') } @xticks) . '}') + ) : ",\nxticklabel=\\empty"; my @xminor_ticks; @@ -250,7 +263,14 @@ sub generate_axes { grep { $_ > $ymin && $_ < $ymax } map { -$_ * $y_tick_distance * $y_tick_scale } reverse(1 .. -$ymin / ($y_tick_distance * $y_tick_scale)); - push(@yticks, 0) if $ymin < 0 && $ymax > 0; + + # If there are custom labels and 0 is one of the ticks, an empty label needs to be added. + my @custom_ylabels; + @custom_ylabels = @{ $axes->yaxis('tick_custom_labels') } if $axes->yaxis('tick_custom_labels'); + if ($ymin < 0 && $ymax > 0) { + push(@yticks, 0); + unshift(@custom_ylabels, '') if @custom_ylabels; + } push(@yticks, grep { $_ > $ymin && $_ < $ymax } map { $_ * $y_tick_distance * $y_tick_scale } (1 .. $ymax / ($y_tick_distance * $y_tick_scale))); @@ -259,8 +279,13 @@ sub generate_axes { $yvisible && $axes->yaxis('show_ticks') && $axes->yaxis('tick_labels') - ? (",\nyticklabel shift=-3pt,\nyticklabels={" - . join(',', map { $self->formatTickLabelText($_ / $y_tick_scale, 'yaxis') } @yticks) . '}') + ? ( + $axes->yaxis('tick_custom_labels') + ? (",\nyticklabel shift=-3pt,\nyticklabel style={anchor=left},\nyticklabels={" + . join(',', @custom_ylabels) . '}') + : (",\nyticklabel shift=-3pt,\nyticklabels={" + . join(',', map { $self->formatTickLabelText($_ / $y_tick_scale, 'yaxis') } @yticks) . '}') + ) : ",\nyticklabel=\\empty"; my @yminor_ticks; diff --git a/macros/graph/StatisticalPlots.pl b/macros/graph/StatisticalPlots.pl index 253d45159..5e50f9502 100644 --- a/macros/graph/StatisticalPlots.pl +++ b/macros/graph/StatisticalPlots.pl @@ -44,17 +44,18 @@ =head2 USAGE L. Note that each of the x- and y-axes have separate options and each option is preceded with a C or C. -After the C is created then specific plots are added to the axes. For example: +After a C object is created then specific plots are added to the axes. For example: - @y = (3, 6, 7, 8, 4, 1); $hist->add_barplot( - [ 1 .. 6 ], ~~@y, - fill_color => 'yellow', - width => 1, - bar_width => 0.9 + [ 1 .. 6 ], + [3, 6, 7, 8, 4, 1], + fill_color => 'yellow', + stroke_width => 1, + bar_width => 0.9 ); -will add a barplot to the axes with heights in the C<@y> variable at the x-locations C<(1..6)>. +will add a barplot to the axes with heights defined in the second argument at +the x-locations C<(1..6)>. See below for more details about creating a barplot and its options. @@ -66,34 +67,35 @@ =head1 PLOT ELEMENTS =head2 BAR PLOTS -A bar plot is added with the C method to a C. The general form for a -bar plot with vertical bars (the default) is +A bar plot can be added using the C<< $stat_plot->add_barplot >> method. $stat_plot->add_barplot($xdata, $ydata, %opts); -where C<$xdata> is an array reference of x-values where the bars will be centered and C<$ydata> is an -ARRAY of heights of the bars. Note: if the option C<< orientation => 'horizontal' >> is included -then the bar lengths are the values in C<$xdata> and locations in C<$ydata>. +This adds vertical bars (as the default) centered at the array reference C<$xdata> +with heights C<$ydata>, an array reference. =head3 OPTIONS The options for the C method are two fold. The following are specific to changing the barplot, and the rest are passed along to C, which is a wrapper function for -C. +C which draws the bars. + +The following are options for the barplot itself: =over =item orientation -The C option can take on C (default) or C to make vertical -or horizontal bars. Above was an example with vertical bars and an example with horizontal bars is +The C option can take on values C<'vertical'> (default) or C<'horizontal'> to +create vertical or horizontal bars. Above was an example with vertical bars and +an example with horizontal bars is - @x = (3, 6, 7, 8, 4, 1); $hist->add_barplot( - ~~@x, [ 1 .. 6 ], + [3, 6, 7, 8, 4, 1], + [ 1 .. 6 ], orientation => 'horizontal', fill_color => 'yellow', - width => 1, + stroke_width => 1, bar_width => 0.9 ); @@ -101,21 +103,30 @@ =head3 OPTIONS The option C is a number in the range [0,1] to give the relative width of the bar. If C<< bar_width => 1 >> (default), then there is no gap between bars. In the example above, with -C<< bar_width => 0.9 >>, there is a small gap between bars. +C<< bar_width => 0 + +=item fill_color + +This is the color of the bars, which is passed to the C method. If this is included +then C is set to C<'self'>, the natural way to fill a rectangle. + +See L for more details on specifying colors. + +=item stroke_color + +This is an alias for the C option of the C method. This +specifies the color of the boundary of the rectangle. See L +for more details on specifying colors. =back Any remaining options are passed to C which has the same options as C, -however, if C is passed to C, then the C<< fill => 'self' >> is also -passed along. - -See L for specifics about other options to -both changing fill and stroke color. +however. See L for other options. =head2 HISTOGRAMS -A L is added with the `add_histogram` method -to a C. The general form is +A L is added to a C +with the `add_histogram` method. The general form is $stat_plot->add_histogram($data, %options); @@ -168,13 +179,21 @@ =head3 Options =item normalize If the value of 0 (default) is used, the height of the bars is the count of the number -of points. If the value is 1, then the heights are scaled so the total height of the -bars is 1. +of points within each bin. If the value is 1, then the heights are scaled so +the total height of the bars is 1. + +=item fill_color + +This is the color of the bars, which is passed to the C method. If this is included +then C is set to C<'self'>, the natural way to fill a rectangle. + +See L for more details on specifying colors. =item stroke_color This sets the color of the boundary of the rectangle and the whiskers. It is an alias for -the C option of L. See L for options to change the color. +the C option of L. See L +for options to change the color. =item stroke_width @@ -198,11 +217,12 @@ =head2 BOX PLOTS $stat_plot->add_boxplot([$data1, $data2, ...], %options); -where C<$data> is an array ref of univariate data or a hash ref of the boxplot characteristics, -then a box plot is created using the five number summary (minimum, first quartile, median, -third quartile, maximum) of the data. These values are calculated using the C -function from C. An example of creating a boxplot with an array reference -of univariate data is +where C<$data> (or C<$data1>, C<$data2>, ...) is an array ref of univariate data +or a hash ref of the boxplot characteristics, then a box plot is created using +the five number summary (minimum, first quartile, median, third quartile, maximum) +of the data. These values are calculated using the C +function from C. An example of creating a boxplot with an +array reference of univariate data is @data = urand(100,25,75,6); @@ -218,7 +238,7 @@ =head2 BOX PLOTS rounded_corners => 1 ); - $boxplot->add_boxplot(~~@data, fill_color => 'lightblue', width => 1); + $boxplot->add_boxplot(~~@data, fill_color => 'LightBlue', stroke_width => 1); and as with other methods in this macro, one can pass options to the characteristic of the box plot (like fill color or stroke color and width) within the C method. @@ -277,52 +297,59 @@ =head3 Options =item box_width -The width of the box in the direction perpendicular to the orientation. If not define, it +The width of the box in the direction perpendicular to the orientation. If not defined, it will take the value of 0.5 times the space between the axis and the edge of the plot. If multiple box plots are defined, this should only be a single value. =item whisker_cap -Value of 0 (default) or 1. If 1, his will add a short line perpendicular to the whiskers -on the boxplot with relative size C +Value of 0 (default) or 1. If this value is 1, a short line will be added that is +perpendicular to the whiskers on the boxplot with relative size C. =item cap_width -The width of the cap as a fraction of the box height (if C<< orientation => 'vertical' >>) -or box width (if C<< orientation => 'horizontal' >>). Default value is 0.2. +The width of the cap as a fraction of the box width. Default value is 0.2. =item outlier_mark -The shape of the mark to use for outliers. Default is 'plus'. See L -for other mark options. +The shape of the mark to use for outliers. Default is 'plus'. See +L for other mark options. + +=item fill_color + +This is the color of the bars, which is passed to the C method. +If this is included then C is set to C<'self'>, the natural way to +fill a rectangle. + +See L for more details on specifying colors. =item stroke_color -This sets the color of the boundary of the rectangle and the whiskers. It is an alias for -the C option of L. See L for options to change the color. +This sets the color of the boundary of the rectangle and the whiskers. It is an +alias for the C option of L. +See L for options to change the color. =item stroke_width -This sets the width of the boundary of the rectangle and the whiskers. This is an alias for -the C option of L. +This sets the width of the boundary of the rectangle and the whiskers. This is +an alias for the C option of L. =back -As with other methods in the macro, other options can be passed along to L -and C which are used in the macro. +As with other methods in the macro, other options can be passed along to +L and C which are used in the macro. -Also, if C is included, then C<< fill => 'self' >> is automatically added on the -box. =head2 SCATTER PLOTS To produce a scatter plot, use the C method to a C. The general form is - $plot->add_scatterplot($data, %options); + $stat_plot->add_scatterplot($data, %options); -where the dataset in C<$data> is an array ref of C pairs as an array ref. For example, +where the dataset in C<$data> is an array reference of C pairs as an array +reference. For example, $stat_plot = StatPlot( xmin => -1, @@ -398,15 +425,25 @@ =head2 PIE CHARTS =item angle_offset -The first sector by default starts at angle 0 (from the positive horizontal axis) in degrees. Use -this to change this. +The first sector by default starts at angle 0 (from the positive horizontal axis) +in degrees. Use this to change this. + +=item fill_colors -=item color_palette +This is either the name of a color palette (as a string), an array reference of +colors or a hash reference for the name of the color palette and number of colors +to generate (not available for all palettes). If the length of this array reference +is smaller than the C<$data> array reference, then the colors will be cycled. +The default is to use the 'default' color palette. See L for +more information. -This is either the name of a color palette or an array reference of colors for each of the -sectors in the pie chart. If the length of this array reference is smaller than -the C<$data> array reference, then the colors will be cycled. The default is to -use the 'default' color palette. See L for more information. +Usage: the following are possible options. + + fill_colors => 'rainbow' # generates the rainbow palette + + fill_colors => ['green', 'OliveGreen', 'DarkGreen', 'ForestGreen', 'PineGreen'] + + fill_colors => {palette_name => 'random', num_colors => 7} =item color_sectors @@ -427,7 +464,7 @@ =head2 COLOR PALETTES function. This allows a number of built-in/generated color palettes. To get an array reference of either named or generated colors: - color_palette($name, num_colors => $n); + color_palette($name, $n); For example, @@ -450,11 +487,26 @@ =head3 PALETTE NAMES This will return C random colors from the defined SVG colors. +=item reds + +This will return a selection of red colors. If C is passed in, +the number is ignored. + +=item blues + +This will return a selection of blue colors. If C is passed in, +the number is ignored. + +=item greens + +This will return a selection of green colors. If C is passed in, +the number is ignored. + =back =head2 LEGENDS -A legend is helpful for some plots. +TODO: A legend is helpful for some plots. =cut @@ -686,7 +738,9 @@ sub add_piechart { unless defined($options{labels}) && scalar(@$data) == scalar(@{ $options{labels} }); my $fill_colors = - (!defined $options{fill_colors} || ref $options{fill_colors} ne 'ARRAY') + ref $options{fill_colors} eq 'HASH' + ? color_palette($options{fill_colors}{palette_name}, $options{fill_colors}{num_colors}) + : (!defined $options{fill_colors} || ref $options{fill_colors} ne 'ARRAY') ? color_palette($options{fill_colors}) : $options{fill_colors}; @@ -766,6 +820,55 @@ sub color_palette { if ($palette_name eq 'rainbow') { return [ 'violet', 'blue', 'green', 'yellow', 'orange', 'red' ]; + } elsif ($palette_name eq 'greens') { + return [ 'green', 'Olive', 'DarkGreen', 'LawnGreen', 'MediumAquaMarine', 'LimeGreen' ]; + } elsif ($palette_name eq 'blues') { + return [ 'blue', 'MidnightBlue', 'MediumBlue', 'LightSkyBlue', 'DodgerBlue', 'DarkBlue', 'CornflowerBlue' ]; + } elsif ($palette_name eq 'reds') { + return [ 'red', 'Crimson', 'DarkRed', 'FireBrick', 'IndianRed', 'Maroon', 'Tomato' ]; + } elsif ($palette_name eq 'random') { + my @all_colors = ( + 'AliceBlue', 'AntiqueWhite', 'Aqua', 'Aquamarine', + 'Azure', 'Beige', 'Bisque', 'Black', + 'BlanchedAlmond', 'Blue', 'BlueViolet', 'Brown', + 'BurlyWood', 'CadetBlue', 'Chartreuse', 'Chocolate', + 'Coral', 'CornflowerBlue', 'Cornsilk', 'Crimson', + 'Cyan', 'DarkBlue', 'DarkCyan', 'DarkGoldenrod', + 'DarkGray', 'DarkGreen', 'DarkGrey', 'DarkKhaki', + 'DarkMagenta', 'DarkOliveGreen', 'DarkOrange', 'DarkOrchid', + 'DarkRed', 'DarkSalmon', 'DarkSeaGreen', 'DarkSlateBlue', + 'DarkSlateGray', 'DarkSlateGrey', 'DarkTurquoise', 'DarkViolet', + 'DeepPink', 'DeepSkyBlue', 'DimGray', 'DimGrey', + 'DodgerBlue', 'FireBrick', 'FloralWhite', 'ForestGreen', + 'Fuchsia', 'Gainsboro', 'GhostWhite', 'Gold', + 'Goldenrod', 'Gray', 'Green', 'GreenYellow', + 'Grey', 'Honeydew', 'HotPink', 'IndianRed', + 'Indigo', 'Ivory', 'Khaki', 'Lavender', + 'LavenderBlush', 'LawnGreen', 'LemonChiffon', 'LightBlue', + 'LightCoral', 'LightCyan', 'LightGoldenrodYellow', 'LightGray', + 'LightGreen', 'LightGrey', 'LightPink', 'LightSalmon', + 'LightSeaGreen', 'LightSkyBlue', 'LightSlateGray', 'LightSlateGrey', + 'LightSteelBlue', 'LightYellow', 'Lime', 'LimeGreen', + 'Linen', 'Magenta', 'Maroon', 'MediumAquamarine', + 'MediumBlue', 'MediumOrchid', 'MediumPurple', 'MediumSeaGreen', + 'MediumSlateBlue', 'MediumSpringGreen', 'MediumTurquoise', 'MediumVioletRed', + 'MidnightBlue', 'MintCream', 'MistyRose', 'Moccasin', + 'NavajoWhite', 'Navy', 'OldLace', 'Olive', + 'OliveDrab', 'Orange', 'OrangeRed', 'Orchid', + 'PaleGoldenrod', 'PaleGreen', 'PaleTurquoise', 'PaleVioletRed', + 'PapayaWhip', 'PeachPuff', 'Peru', 'Pink', + 'Plum', 'PowderBlue', 'Purple', 'RebeccaPurple', + 'Red', 'RosyBrown', 'RoyalBlue', 'SaddleBrown', + 'Salmon', 'SandyBrown', 'SeaGreen', 'Seashell', + 'Sienna', 'Silver', 'SkyBlue', 'SlateBlue', + 'SlateGray', 'SlateGrey', 'Snow', 'SpringGreen', + 'SteelBlue', 'Tan', 'Teal', 'Thistle', + 'Tomato', 'Turquoise', 'Violet', 'Wheat', + 'White', 'WhiteSmoke', 'Yellow', 'YellowGreen' + ); + + $num_colors = 10 unless defined($num_colors); + return [ map { $all_colors[$_] } main::random_subset($num_colors, 0 .. $#all_colors) ]; } }