diff --git a/conf/pg_config.dist.yml b/conf/pg_config.dist.yml index 9fedd6e30..8484d612e 100644 --- a/conf/pg_config.dist.yml +++ b/conf/pg_config.dist.yml @@ -230,6 +230,7 @@ modules: - [Multiple] - [PGrandom] - [Regression] + - ['Plots::Plot', 'Plots::Axes', 'Plots::Data', 'Plots::Tikz', 'Plots::JSX', 'Plots::Plotly', 'Plots::GD'] - [Select] - [Units] - [VectorField] diff --git a/lib/Plots/Axes.pm b/lib/Plots/Axes.pm new file mode 100644 index 000000000..e7036c2c3 --- /dev/null +++ b/lib/Plots/Axes.pm @@ -0,0 +1,334 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2023 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +=head1 AXES OBJECT + +This is a hash to store information about the axes (ticks, range, grid, etc) +with some helper methods. The hash is further split into three smaller hashes: + +=over 5 + +=item xaxis + +Hash of data for the horizontal axis. + +=item yaxis + +Hash of data for the vertical axis. + +=item styles + +Hash of data for options for the general axis. + +=back + +=head1 USAGE + +The axes object should be accessed through a Plots object using C<< $plot->axes >>. +The axes object is used to configure and retrieve information about the axes, +as in the following examples. + +Each axis can be configured individually, such as: + + $plot->axes->xaxis(min => -10, max => 10, ticks => [-12, -8, -4, 0, 4, 8, 12]); + $plot->axes->yaxis(min => 0, max => 100, ticks => [20, 40, 60, 80, 100]); + +This can also be combined using the set method, such as: + + $plot->axes->set( + xmin => -10, + xmax => 10, + xticks => [-12, -8, -4, 0, 4, 8, 12], + ymin => 0, + ymax => 100, + yticks => [20, 40, 60, 80, 100] + ); + +In addition to the configuration each axis, there is a set of styles that apply to both axes. +These are access via the style method. To set one or more styles use: + + $plot->axes->style(title => '\(y = f(x)\)', show_grid => 0); + +The same methods also get the value of a single option, such as: + + $xmin = $plot->axes->xaxis('min'); + $yticks = $plot->axes->yaxis('ticks'); + $title = $plot->axes->style('title'); + +The methods without any inputs return a reference to the full hash, such as: + + $xaxis = $plot->axes->xaxis; + $styles = $plot->axes->style; + +It is also possible to get multiple options for both axes using the get method, which returns +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 + $grid = $plot->axes->get('xmajor', 'xminor', 'xticks', 'ymajor', 'yminor', 'yticks'); + +It is also possible to get the bounds as an array in the order xmin, ymin, xmax, ymax +using the C<< $plot->axes->bounds >> method. + +=head1 AXIS CONFIGURATION OPTIONS + +Each axis (the xaxis and yaxis) has the following configuration options: + +=over 5 + +=item min + +The minimum value the axis shows. Default is -5. + +=item max + +The maximum value the axis shows. Default is 5. + +=item ticks + +An array which lists the major tick marks. If this array is empty, the ticks are +generated using either C or C. Default is C<[]>. + +=item tick_delta + +This is the distance between each major tick mark, starting from the origin. +This distance is then used to generate the tick marks if the ticks array is empty. +If this is set to 0, this distance is set by using the number of ticks, C. +Default is 0. + +=item tick_num + +This is the number of major tick marks to include on the axis. This number is used +to compute the C as the difference between the C and C values +and the number of ticks. Default: 5. + +=item label + +The axis label. Defaults are C<\(x\)> and C<\(y\)>. + +=item major + +Show (1) or don't show (0) grid lines at the tick marks. Default is 1. + +=item minor + +This sets the number of minor grid lines per major grid line. If this is +set to 0, no minor grid lines are shown. Default is 3. + +=item visible + +This sets if the axis is shown (1) or not (0) on the plot. Default is 1. + +=item location + +This sets the location of the axes relative to the graph. The possible options +for each axis are: + + xaxis => 'box', 'top', 'middle', 'bottom' + yaxis => 'box', 'left', 'center', 'right' + +This places the axis at the appropriate edge of the graph. If 'center' or 'middle' +are used, the axes appear on the inside of the graph at the appropriate position. +Setting the location to 'box' creates a box or framed pot. Default 'middle' or 'center'. + +=item position + +The position in terms of the appropriate variable to draw the axis if the location is +set to 'middle' or 'center'. Default is 0. + +=back + +=head1 STYLES + +The following styles configure aspects about the axes: + +=over 5 + +=item title + +The title of the graph. Default is ''. + +=item show_grid + +Either draw (1) or don't draw (0) the grid lines for the axis. Default is 1. + +=item grid_color + +The color of the grid lines. Default is 'gray'. + +=item grid_style + +The line style of grid lines. This can be 'dashed', 'dotted', 'solid', etc. +Default is 'solid'. + +=item grid_alpha + +The alpha value to use to draw the grid lines in Tikz. This is a number from +0 (fully transparent) to 100 (fully solid). Default is 40. + +=item axis_on_top + +Configures if the axis should be drawn on top of the graph (1) or below the graph (0). +Useful when filling a region that covers an axis, if the axis are on top they will still +be visible after the fill, otherwise the fill will cover the axis. Default: 0 + +=back + +=cut + +package Plots::Axes; + +use strict; +use warnings; + +sub new { + my $class = shift; + my $self = bless { + xaxis => {}, + yaxis => {}, + styles => { + title => '', + grid_color => 'gray', + grid_style => 'solid', + grid_alpha => 40, + show_grid => 1, + }, + @_ + }, $class; + + $self->xaxis($self->axis_defaults('x')); + $self->yaxis($self->axis_defaults('y')); + return $self; +} + +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, + ticks => undef, + tick_delta => 0, + tick_num => 5, + major => 1, + minor => 3, + ); +} + +sub axis { + my ($self, $axis, @items) = @_; + return $self->{$axis} unless @items; + if (scalar(@items) > 1) { + my %item_hash = @items; + map { $self->{$axis}{$_} = $item_hash{$_}; } (keys %item_hash); + return; + } + my $item = $items[0]; + if (ref($item) eq 'HASH') { + map { $self->{$axis}{$_} = $item->{$_}; } (keys %$item); + return; + } + # Deal with ticks individually since they may need to be generated. + return $item eq 'ticks' ? $self->{$axis}{ticks} || $self->gen_ticks($self->axis($axis)) : $self->{$axis}{$item}; +} + +sub xaxis { + my ($self, @items) = @_; + return $self->axis('xaxis', @items); +} + +sub yaxis { + my ($self, @items) = @_; + return $self->axis('yaxis', @items); +} + +sub set { + my ($self, %options) = @_; + my (%xopts, %yopts); + for (keys %options) { + if ($_ =~ s/^x//) { + $xopts{$_} = $options{"x$_"}; + } elsif ($_ =~ s/^y//) { + $yopts{$_} = $options{"y$_"}; + } + } + $self->xaxis(%xopts) if %xopts; + $self->yaxis(%yopts) if %yopts; + return; +} + +sub get { + my ($self, @keys) = @_; + my %options; + for (@keys) { + if ($_ =~ s/^x//) { + $options{"x$_"} = $self->xaxis($_); + } elsif ($_ =~ s/^y//) { + $options{"y$_"} = $self->yaxis($_); + } + } + return \%options; +} + +sub style { + my ($self, @styles) = @_; + return $self->{styles} unless @styles; + if (scalar(@styles) > 1) { + my %style_hash = @styles; + map { $self->{styles}{$_} = $style_hash{$_}; } (keys %style_hash); + return; + } + my $style = $styles[0]; + if (ref($style) eq 'HASH') { + map { $self->{styles}{$_} = $style->{$_}; } (keys %$style); + return; + } + return $self->{styles}{$style}; +} + +sub gen_ticks { + my ($self, $axis) = @_; + my $min = $axis->{min}; + my $max = $axis->{max}; + my $delta = $axis->{tick_delta}; + $delta = ($max - $min) / $axis->{tick_num} unless $delta; + + my @ticks = $min <= 0 && $max >= 0 ? (0) : (); + my $point = $delta; + # Adjust min/max to place one more tick beyond the graph's edge. + $min -= $delta; + $max += $delta; + do { + push(@ticks, $point) unless $point < $min || $point > $max; + unshift(@ticks, -$point) unless -$point < $min || -$point > $max; + $point += $delta; + } until (-$point < $min && $point > $max); + return \@ticks; +} + +sub grid { + my $self = shift; + return $self->get('xmajor', 'xminor', 'xticks', 'ymajor', 'yminor', 'yticks'); +} + +sub bounds { + my $self = shift; + return $self->{xaxis}{min}, $self->{yaxis}{min}, $self->{xaxis}{max}, $self->{yaxis}{max}; +} + +1; diff --git a/lib/Plots/Data.pm b/lib/Plots/Data.pm new file mode 100644 index 000000000..abfad1068 --- /dev/null +++ b/lib/Plots/Data.pm @@ -0,0 +1,212 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2023 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +=head1 DATA OBJECT + +This object holds data about the different types of elements that can be added to a +Plots graph. This is a hash with some helper methods. Data objects are created and +modified using the Plots methods, and do not need to generally be modified in a PG +problem. Each PG add method returns the related data object which can be used if needed. + +Each data object contains the following: + +=over 5 + +=item name + +The name is used to identify what type of data is being stored, +such as a function, dataset, label, etc. + +=item x + +The array of the data points x-value. + +=item y + +The array of the data points y-value. + +=item function + +A function (stored as a hash) to generate the x and y data points. + +=item styles + +An hash of different style options and values that can be used +to store additional data for things like color, width, etc. + +=back + +=head1 USAGE + +The main methods for adding data and accessing the data are: + +=over 5 + +=item C<< $data->name >> + +Sets, C<< $data->name($string) >>, or gets C<< $data->name >> the name of the data object. + +=item C<< $data->add >> + +Adds a single data point, C<< $data->add($x, $y) >>, or adds multiple data points, +C<< $data->add([$x1, $y1], [$x2, $y2], ..., [$xn, $yn]) >>. + +=item C<< $data->set_function >> + +Configures a function to generate data points. C and C are are perl subroutines. + + $data->set_function( + sub_x => sub { return $_[0]; }, + sub_y => sub { return $_[0]**2; }, + min => -5, + max => 5, + ); + +The number of steps used to generate the data is a style and needs to be set separately. + + $data->style(steps => 50); + +=item C<< $data->gen_data >> + +Generate the data points from a function. This can only be done when there is no data, so +once the data has been generated this will do nothing (to avoid generating data again). + +=item C<< $data->size >> + +Returns the current number of points being stored. + +=item C<< $data->x >> and C<< $data->y >> + +Without any inputs, these return either the x array or y array of data points being stored. +A single input can be used to return only the n-th data point, C<< $data->x($n) >>. + +=item C<< $data->style >> + +Sets or gets style information. Use C<< $data->style($name) >> to get the style value of a single +style name. C<< $data->style >> will returns a reference to the full style hash. Last, input a hash +to add / change the styles. + + $data->style(color => 'blue', width => 3); + +=back + +=cut + +package Plots::Data; + +use strict; +use warnings; + +sub new { + my ($class, %options) = @_; + return bless { name => '', x => [], y => [], function => {}, styles => {}, %options }, $class; +} + +sub name { + my ($self, $name) = @_; + return $self->{name} unless $name; + $self->{name} = $name; + return; +} + +sub size { + my $self = shift; + return scalar(@{ $self->{x} }); +} + +sub x { + my ($self, $n) = @_; + return $self->{x}->[$n] if (defined($n) && defined($self->{x}->[$n])); + return wantarray ? @{ $self->{x} } : $self->{x}; +} + +sub y { + my ($self, $n) = @_; + return $self->{y}[$n] if (defined($n) && defined($self->{y}[$n])); + return wantarray ? @{ $self->{y} } : $self->{y}; +} + +sub style { + my ($self, @styles) = @_; + return $self->{styles} unless @styles; + if (scalar(@styles) > 1) { + my %style_hash = @styles; + map { $self->{styles}{$_} = $style_hash{$_}; } (keys %style_hash); + return; + } + my $style = $styles[0]; + if (ref($style) eq 'HASH') { + map { $self->{styles}{$_} = $style->{$_}; } (keys %$style); + return; + } + return $self->{styles}{$style}; +} + +sub set_function { + my ($self, %options) = @_; + $self->{function} = { + sub_x => sub { return $_[0]; }, + sub_y => sub { return $_[0]; }, + min => -5, + max => 5, + %options + }; + $self->style(steps => $self->{function}{steps}) if $self->{funciton}{steps}; + return; +} + +sub _stepsize { + my $self = shift; + my $f = $self->{function}; + my $steps = $self->style('steps') || 20; + # Using MathObjects allows bounds like 2pi/3, e^2, et, etc. + $f->{min} = &main::Real($f->{min})->value if ($f->{min} =~ /[^\d\-\.]/); + $f->{max} = &main::Real($f->{max})->value if ($f->{max} =~ /[^\d\-\.]/); + return ($f->{max} - $f->{min}) / $steps; +} + +sub gen_data { + my $self = shift; + my $f = $self->{function}; + return if !$f || $self->size; + my $steps = $self->style('steps') || 20; + my $dt = $self->_stepsize; + my $t = $f->{min}; + for (0 .. $steps) { + $self->add(&{ $f->{sub_x} }($t), &{ $f->{sub_y} }($t)); + $t += $dt; + } + return; +} + +sub _add { + my ($self, $x, $y) = @_; + return unless defined($x) && defined($y); + push(@{ $self->{x} }, $x); + push(@{ $self->{y} }, $y); + return; +} + +sub add { + my ($self, @points) = @_; + if (ref($points[0]) eq 'ARRAY') { + for (@points) { $self->_add(@$_); } + } else { + $self->_add(@points); + } + return; +} + +1; diff --git a/lib/Plots/GD.pm b/lib/Plots/GD.pm new file mode 100644 index 000000000..f83d7ff9d --- /dev/null +++ b/lib/Plots/GD.pm @@ -0,0 +1,388 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2023 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +=head1 DESCRIPTION + +This is the code that takes a C and creates the GD code for generation. + +See L for more details. + +=cut + +package Plots::GD; + +use GD; + +use strict; +use warnings; + +sub new { + my ($class, $pgplot) = @_; + return bless { + image => '', + pgplot => $pgplot, + position => [ 0, 0 ], + colors => {}, + image => GD::Image->new($pgplot->size) + }, $class; +} + +sub pgplot { + my $self = shift; + return $self->{pgplot}; +} + +sub im { + my $self = shift; + return $self->{image}; +} + +sub position { + my ($self, $x, $y) = @_; + return wantarray ? @{ $self->{position} } : $self->{position} unless (defined($x) && defined($y)); + $self->{position} = [ $x, $y ]; + return; +} + +sub color { + my ($self, $color) = @_; + $self->{colors}{$color} = $self->im->colorAllocate(@{ $self->pgplot->colors($color) }) + unless $self->{colors}{$color}; + return $self->{colors}{$color}; +} + +# Translate x and y coordinates to pixels on the graph. +sub im_x { + my ($self, $x) = @_; + return unless defined($x); + my $pgplot = $self->pgplot; + my ($xmin, $xmax) = ($pgplot->axes->xaxis('min'), $pgplot->axes->xaxis('max')); + return int(($x - $xmin) * $pgplot->{width} / ($xmax - $xmin)); +} + +sub im_y { + my ($self, $y) = @_; + return unless defined($y); + my $pgplot = $self->pgplot; + my ($ymin, $ymax) = ($pgplot->axes->yaxis('min'), $pgplot->axes->yaxis('max')); + return int(($ymax - $y) * $pgplot->{height} / ($ymax - $ymin)); +} + +sub moveTo { + my ($self, $x, $y) = @_; + $x = $self->im_x($x); + $y = $self->im_y($y); + $self->position($x, $y); + return; +} + +sub lineTo { + my ($self, $x, $y, $color, $width, $dashed) = @_; + $color = 'default_color' unless defined($color); + $color = $self->color($color); + $width = 1 unless defined($width); + $dashed = 0 unless defined($dashed); + $x = $self->im_x($x); + $y = $self->im_y($y); + + $self->im->setThickness($width); + if ($dashed =~ /dash/) { + my @dashing = ($color) x (4 * $width * $width); + my @spacing = (GD::gdTransparent) x (3 * $width * $width); + $self->im->setStyle(@dashing, @spacing); + $self->im->line($self->position, $x, $y, GD::gdStyled); + } elsif ($dashed =~ /dot/) { + my @dashing = ($color) x (1 * $width * $width); + my @spacing = (GD::gdTransparent) x (2 * $width * $width); + $self->im->setStyle(@dashing, @spacing); + $self->im->line($self->position, $x, $y, GD::gdStyled); + } else { + $self->im->line($self->position, $x, $y, $color); + } + $self->im->setThickness(1); + $self->position($x, $y); + return; +} + +# Draw functions / lines / arrows +sub draw_data { + my ($self, $pass) = @_; + my $pgplot = $self->pgplot; + $pass = 0 unless $pass; + for my $data ($pgplot->data('function', 'dataset')) { + $data->gen_data; + my $n = $data->size - 1; + my $x = $data->x; + my $y = $data->y; + my $color = $data->style('color'); + my $width = $data->style('width'); + $self->moveTo($x->[0], $y->[0]); + for (1 .. $n) { + $self->lineTo($x->[$_], $y->[$_], $color, $width, $data->style('linestyle')); + } + + if ($pass == 2) { + my $r = int(3 + $width); + my $start = $data->style('start_mark') || 'none'; + if ($start eq 'closed_circle') { + $self->draw_circle_stamp($data->x(0), $data->y(0), $r, $color, 1); + } elsif ($start eq 'open_circle') { + $self->draw_circle_stamp($data->x(0), $data->y(0), $r, $color); + } elsif ($start eq 'arrow') { + $self->draw_arrow_head($data->x(1), $data->y(1), $data->x(0), $data->y(0), $color, $width); + } + + my $end = $data->style('end_mark') || 'none'; + if ($end eq 'closed_circle') { + $self->draw_circle_stamp($data->x($n), $data->y($n), $r, $color, 1); + } elsif ($end eq 'open_circle') { + $self->draw_circle_stamp($data->x($n), $data->y($n), $r, $color); + } elsif ($end eq 'arrow') { + $self->draw_arrow_head($data->x($n - 1), $data->y($n - 1), $data->x($n), $data->y($n), $color, $width); + } + } + } + return; +} + +# Label helpers +sub get_gd_font { + my ($self, $font) = @_; + if ($font eq 'tiny') { return GD::gdTinyFont; } + elsif ($font eq 'small') { return GD::gdSmallFont; } + elsif ($font eq 'large') { return GD::gdLargeFont; } + elsif ($font eq 'giant') { return GD::gdGiantFont; } + return GD::gdMediumBoldFont; +} + +sub label_offset { + my ($self, $loc, $str, $fontsize) = @_; + my $offset = 0; + # Add an additional 2px offset for the edges 'right', 'bottom', 'left', and 'top'. + if ($loc eq 'right') { $offset -= length($str) * $fontsize + 2; } + elsif ($loc eq 'bottom') { $offset -= $fontsize + 2; } + elsif ($loc eq 'center') { $offset -= length($str) * $fontsize / 2; } + elsif ($loc eq 'middle') { $offset -= $fontsize / 2; } + else { $offset = 2; } # Both 'left' and 'top'. + return $offset; +} + +sub draw_label { + my ($self, $str, $x, $y, %options) = @_; + my $font = $self->get_gd_font($options{fontsize} || 'medium'); + my $color = $self->color($options{color} || 'default_color'); + my $xoff = $self->label_offset($options{h_align} || 'center', $str, $font->width); + my $yoff = $self->label_offset($options{v_align} || 'middle', $str, $font->height); + + if ($options{orientation} && $options{orientation} eq 'vertical') { + $self->im->stringUp($font, $self->im_x($x) + $xoff, $self->im_y($y) + $yoff, $str, $color); + } else { + $self->im->string($font, $self->im_x($x) + $xoff, $self->im_y($y) + $yoff, $str, $color); + } + return; +} + +sub draw_arrow_head { + my ($self, $x1, $y1, $x2, $y2, $color, $w) = @_; + return unless @_ > 4; + $color = $self->color($color || 'default_color'); + $w = 1 unless $w; + ($x1, $y1) = ($self->im_x($x1), $self->im_y($y1)); + ($x2, $y2) = ($self->im_x($x2), $self->im_y($y2)); + + my $dx = $x2 - $x1; + my $dy = $y2 - $y1; + my $len = sqrt($dx * $dx + $dy * $dy); + my $ux = $dx / $len; # Unit vector in direction of arrow. + my $uy = $dy / $len; + my $px = -1 * $uy; # Unit vector perpendicular to arrow. + my $py = $ux; + my $hbx = $x2 - 7 * $w * $ux; + my $hby = $y2 - 7 * $w * $uy; + my $head = GD::Polygon->new; + $head->addPt($x2, $y2); + $head->addPt($hbx + 3 * $w * $px, $hby + 3 * $w * $py); + $head->addPt($hbx - 3 * $w * $px, $hby - 3 * $w * $py); + $self->im->setThickness($w); + $self->im->filledPolygon($head, $color); + $self->im->setThickness(1); + return; +} + +sub draw_circle_stamp { + my ($self, $x, $y, $r, $color, $filled) = @_; + my $d = $r ? 2 * $r : 8; + $color = $self->color($color || 'default_color'); + $self->im->filledArc($self->im_x($x), $self->im_y($y), $d, $d, 0, 360, $self->color('nearwhite')); + $self->im->filledArc($self->im_x($x), $self->im_y($y), $d, $d, 0, 360, $color, $filled ? () : GD::gdNoFill); + return; +} + +sub draw { + my $self = shift; + my $pgplot = $self->pgplot; + my $axes = $pgplot->axes; + my $grid = $axes->grid; + my $width = $pgplot->{width}; + my $height = $pgplot->{height}; + + # Initialize image + $self->im->interlaced('true'); + $self->im->fill(1, 1, $self->color('background_color')); + + # Plot data first, then fill in regions before adding axes, grid, etc. + $self->draw_data(1); + + # Fill regions + for my $region ($pgplot->data('fill_region')) { + $self->im->fill($self->im_x($region->x(0)), $self->im_y($region->y(0)), $self->color($region->style('color'))); + } + + # Gridlines + my ($xmin, $ymin, $xmax, $ymax) = $axes->bounds; + my $grid_color = $axes->style('grid_color'); + my $grid_style = $axes->style('grid_style'); + my $show_grid = $axes->style('show_grid'); + if ($show_grid && $grid->{xmajor}) { + my $xminor = $grid->{xminor} || 0; + my $prevx = $xmin; + my $dx = 0; + my $first = 1; + for my $x (@{ $grid->{xticks} }) { + # Number comparison of $dx and $x - $prevx failed in some tests, so using string comparison. + $xminor = 0 unless ($first || $dx == 0 || $dx eq $x - $prevx); + $dx = $x - $prevx unless $first; + $prevx = $x; + $first = 0; + $self->moveTo($x, $ymin); + $self->lineTo($x, $ymax, $grid_color, 0.5, 1); + } + if ($xminor) { + $dx /= ($xminor + 1); + for my $x (@{ $grid->{xticks} }) { + last if $x == $prevx; + for (1 .. $xminor) { + my $x2 = $x + $dx * $_; + $self->moveTo($x2, $ymin); + $self->lineTo($x2, $ymax, $grid_color, 0.5, 1); + } + } + } + } + if ($show_grid && $grid->{ymajor}) { + my $yminor = $grid->{yminor} || 0; + my $prevy; + my $dy = 0; + my $first = 1; + for my $y (@{ $grid->{yticks} }) { + # Number comparison of $dy and $y - $prevy failed in some tests, so using string comparison. + $yminor = 0 unless ($first || $dy == 0 || $dy eq $y - $prevy); + $dy = $y - $prevy unless $first; + $prevy = $y; + $first = 0; + $self->moveTo($xmin, $y); + $self->lineTo($xmax, $y, $grid_color, 0.5, 1); + } + if ($yminor) { + $dy /= ($yminor + 1); + for my $y (@{ $grid->{yticks} }) { + last if $y == $prevy; + for (1 .. $yminor) { + my $y2 = $y + $dy * $_; + $self->moveTo($xmin, $y2); + $self->lineTo($xmax, $y2, $grid_color, 0.5, 1); + } + } + } + } + + # Plot axes + my $show_x = $axes->xaxis('visible'); + my $show_y = $axes->yaxis('visible'); + my $xloc = $axes->xaxis('location') || 'middle'; + my $yloc = $axes->yaxis('location') || 'center'; + my $xpos = ($yloc eq 'box' || $yloc eq 'left') ? $xmin : $yloc eq 'right' ? $xmax : $axes->yaxis('position'); + my $ypos = ($xloc eq 'box' || $xloc eq 'bottom') ? $ymin : $xloc eq 'top' ? $ymax : $axes->xaxis('position'); + $xpos = $xmin if $xpos < $xmin; + $xpos = $xmax if $xpos > $xmax; + $ypos = $ymin if $ypos < $ymin; + $ypos = $ymax if $ypos > $ymax; + + if ($show_x) { + my $xlabel = $axes->xaxis('label') =~ s/\\[\(\[\)\]]//gr; + my $tick_align = ($self->im_y($ymin) - $self->im_y($ypos) < 5) ? 'bottom' : 'top'; + my $label_align = ($self->im_y($ypos) - $self->im_y($ymax) < 5) ? 'top' : 'bottom'; + my $label_loc = $yloc eq 'right' && ($xloc eq 'top' || $xloc eq 'bottom') ? $xmin : $xmax; + + $self->moveTo($xmin, $ypos); + $self->lineTo($xmax, $ypos, 'black', 1.5, 0); + $self->draw_label( + $xlabel, $label_loc, $ypos, + fontsize => 'large', + v_align => $label_align, + h_align => $label_loc == $xmin ? 'left' : 'right' + ); + for my $x (@{ $grid->{xticks} }) { + $self->draw_label($x, $x, $ypos, font => 'large', v_align => $tick_align, h_align => 'center') + unless ($x == $xpos && $show_y); + } + } + if ($axes->yaxis('visible')) { + my $ylabel = $axes->yaxis('label') =~ s/\\[\(\[\)\]]//gr; + my $tick_align = ($self->im_x($xpos) - $self->im_x($xmin) < 5) ? 'left' : 'right'; + my $label_align = ($self->im_x($xmax) - $self->im_x($xpos) < 5) ? 'right' : 'left'; + my $label_loc = ($yloc eq 'left' && $xloc eq 'top') || ($yloc eq 'right' && $xloc eq 'top') ? $ymin : $ymax; + + $self->moveTo($xpos, $ymin); + $self->lineTo($xpos, $ymax, 'black', 1.5, 0); + $self->draw_label( + $ylabel, $xpos, $label_loc, + fontsize => 'large', + v_align => $label_loc == $ymin ? 'bottom' : 'top', + h_align => $label_align + ); + for my $y (@{ $grid->{yticks} }) { + $self->draw_label($y, $xpos, $y, font => 'large', v_align => 'middle', h_align => $tick_align) + unless ($y == $ypos && $show_x); + } + } + + # Draw data a second time to cleanup any issues with the grid and axes. + $self->draw_data(2); + + # Print Labels + for my $label ($pgplot->data('label')) { + $self->draw_label($label->style('label'), $label->x(0), $label->y(0), %{ $label->style }); + } + + # Draw stamps + for my $stamp ($pgplot->data('stamp')) { + my $symbol = $stamp->style('symbol'); + my $color = $stamp->style('color'); + my $r = $stamp->style('radius') || 4; + if ($symbol eq 'closed_circle') { + $self->draw_circle_stamp($stamp->x(0), $stamp->y(0), $r, $color, 1); + } elsif ($symbol eq 'open_circle') { + $self->draw_circle_stamp($stamp->x(0), $stamp->y(0), $r, $color); + } + } + + # Put a black frame around the picture + $self->im->rectangle(0, 0, $width - 1, $height - 1, $self->color('black')); + + return $pgplot->ext eq 'gif' ? $self->im->gif : $self->im->png; +} + +1; diff --git a/lib/Plots/JSX.pm b/lib/Plots/JSX.pm new file mode 100644 index 000000000..7b052c464 --- /dev/null +++ b/lib/Plots/JSX.pm @@ -0,0 +1,174 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2023 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +=head1 DESCRIPTION + +This is the code that takes a C and creates a jsxgraph graph of the plot. + +See L for more details. + +=cut + +package Plots::JSX; + +use strict; +use warnings; + +sub new { + my ($class, $pgplot) = @_; + + $pgplot->insert_css('node_modules/jsxgraph/distrib/jsxgraph.css'); + $pgplot->insert_js('node_modules/jsxgraph/distrib/jsxgraphcore.js'); + + return bless { pgplot => $pgplot }, $class; +} + +sub pgplot { + my $self = shift; + return $self->{pgplot}; +} + +sub HTML { + my $self = shift; + my $board = $self->{board}; + my $JS = $self->{JS}; + + return < +(() => { + const draw_board = () => { +$JS + } + if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', draw_board); + else draw_board(); +})(); + +END_HTML +} + +sub get_color { + my ($self, $color) = @_; + return sprintf("#%x%x%x", @{ $self->pgplot->colors($color) }); +} + +sub add_curve { + my ($self, $data) = @_; + my $linestyle = $data->style('linestyle'); + return if $linestyle eq 'none'; + + if ($linestyle eq 'densely dashed') { + $linestyle = ',dash: 4, dashScale: true'; + } elsif ($linestyle eq 'loosely dashed') { + $linestyle = ',dash: 3, dashScale: true'; + } elsif ($linestyle =~ /dashed/) { + $linestyle = ',dash: 1, dashScale: true'; + } elsif ($linestyle =~ /dotted/) { + $linestyle = ',dash: 1'; + } else { + $linestyle = ''; + } + + my $name = $self->{name}; + my $color = $self->get_color($data->style('color') || 'default_color'); + my $data_points = '[[' . join(',', $data->x) . '],[' . join(',', $data->y) . ']]'; + my $line_width = $data->style('width') || 2; + + $self->{JS} .= "\n\t\tboard_$name.create('curve', $data_points, " + . "{strokeColor: '$color', strokeWidth: $line_width$linestyle});"; +} + +sub add_points { + my ($self, $data) = @_; + my $mark = $data->style('marks'); + return if !$mark || $mark eq 'none'; + + if ($mark eq 'plus' || $mark eq 'oplus') { + $mark = ',face: "plus"'; + } elsif ($mark eq 'times' || $mark eq 'otimes') { + $mark = ',face: "cross"'; + } elsif ($mark eq 'dash') { + $mark = ',face: "minus"'; + } elsif ($mark eq 'bar') { + $mark = ',face: "divide"'; + } elsif ($mark eq 'diamond') { + $mark = ',face: "diamond"'; + } elsif ($mark eq 'open_circle') { + $mark = ',fillColor: "white"'; + } else { + $mark = ''; + } + + my $name = $self->{name}; + my $size = $data->style('mark_size') || $data->style('width') || 3; + + for my $i (0 .. $data->size - 1) { + $self->{JS} .= + "\n\t\tboard_$name.create('point', [" + . $data->x($i) . ',' + . $data->y($i) . '], ' + . "{fixed: true, withLabel: false, size: $size$mark});"; + } +} + +sub init_graph { + my $self = shift; + my $pgplot = $self->pgplot; + my $axes = $pgplot->axes; + my $grid = $axes->grid; + my $name = $self->{name}; + my $title = $axes->style('title'); + my ($xmin, $ymin, $xmax, $ymax) = $axes->bounds; + my ($height, $width) = $pgplot->size; + my $style = 'display: inline-block; margin: 5px; text-align: center;'; + + $title = "$title" if $title; + $self->{board} = <$title +
+ +END_HTML + $self->{JS} = <pgplot; + my $name = $pgplot->get_image_name =~ s/-/_/gr; + $self->{name} = $name; + + $self->init_graph; + + # Plot Data + for my $data ($pgplot->data('function', 'dataset')) { + $data->gen_data; + $self->add_curve($data); + $self->add_points($data); + } + + return $self->HTML; +} + +1; diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm new file mode 100644 index 000000000..7672ffe26 --- /dev/null +++ b/lib/Plots/Plot.pm @@ -0,0 +1,416 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2023 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +=head1 DESCRIPTION + +This is the main C code for creating a Plot. + +See L for more details. + +=cut + +package Plots::Plot; + +use strict; +use warnings; + +use Plots::Axes; +use Plots::Data; +use Plots::Tikz; +use Plots::Plotly; +use Plots::JSX; +use Plots::GD; + +sub new { + my ($class, $pg, %options) = @_; + my $size = $main::envir{onTheFlyImageSize} || 500; + + my $self = bless { + pg => $pg, + imageName => {}, + type => 'Tikz', + ext => $pg->{displayMode} eq 'TeX' ? 'pdf' : 'svg', + width => $size, + height => $size, + tex_size => 500, + axes => Plots::Axes->new, + colors => {}, + data => [], + %options + }, $class; + + $self->color_init; + return $self; +} + +# Only insert js file if it isn't already inserted. +sub insert_js { + my ($self, $file) = @_; + for my $obj (@{ $self->{pg}{flags}{extra_js_files} }) { + return if $obj->{file} eq $file; + } + push(@{ $self->{pg}{flags}{extra_js_files} }, { file => $file, external => 0, attributes => { defer => undef } }); +} + +# Only insert css file if it isn't already inserted. +sub insert_css { + my ($self, $file) = @_; + for my $obj (@{ $self->{pg}{flags}{extra_css_files} }) { + return if $obj->{file} eq $file; + } + push(@{ $self->{pg}{flags}{extra_css_files} }, { file => $file, external => 0 }); +} + +sub colors { + my ($self, $color) = @_; + return defined($color) ? $self->{colors}{$color} : $self->{colors}; +} + +sub add_color { + my ($self, @colors) = @_; + if (ref($colors[0]) eq 'ARRAY') { + for (@colors) { $self->{colors}{ $_->[0] } = [ @$_[ 1 .. 3 ] ]; } + } else { + $self->{colors}{ $colors[0] } = [ @colors[ 1 .. 3 ] ]; + } + return; +} + +# Define some base colors. +sub color_init { + my $self = shift; + $self->add_color('background_color', 255, 255, 255); + $self->add_color('default_color', 0, 0, 0); + $self->add_color('white', 255, 255, 255); + $self->add_color('black', 0, 0, 0); + $self->add_color('red', 255, 0, 0); + $self->add_color('green', 0, 255, 0); + $self->add_color('blue', 0, 0, 255); + $self->add_color('yellow', 255, 255, 0); + $self->add_color('orange', 255, 100, 0); + $self->add_color('gray', 180, 180, 180); + $self->add_color('nearwhite', 254, 254, 254); + return; +} + +sub size { + my $self = shift; + return wantarray ? ($self->{width}, $self->{height}) : [ $self->{width}, $self->{height} ]; +} + +sub data { + my ($self, @names) = @_; + return wantarray ? @{ $self->{data} } : $self->{data} unless @names; + my @data = grep { + my $name = $_->name; + grep {/^$name$/} @names + } @{ $self->{data} }; + return wantarray ? @data : \@data; +} + +sub add_data { + my ($self, $data) = @_; + push(@{ $self->{data} }, $data); + return; +} + +sub axes { + my $self = shift; + return $self->{axes}; +} + +sub get_image_name { + my $self = shift; + my $ext = $self->ext; + return $self->{imageName}{$ext} if $self->{imageName}{$ext}; + $self->{imageName}{$ext} = $self->{pg}->getUniqueName($ext); + return $self->{imageName}{$ext}; +} + +sub imageName { + my ($self, $name) = @_; + return $self->get_image_name unless $name; + $self->{imageName}{ $self->ext } = $name; + return; +} + +sub image_type { + my ($self, $type, $ext) = @_; + return $self->{type} unless $type; + + # Check type and extension are valid. The first element of @validExt is used as default. + my @validExt; + $type = lc($type); + if ($type eq 'jsx') { + $self->{type} = 'JSX'; + @validExt = ('html'); + } elsif ($type eq 'plotly') { + $self->{type} = 'Plotly'; + @validExt = ('html'); + } elsif ($type eq 'tikz') { + $self->{type} = 'Tikz'; + @validExt = ('svg', 'png', 'pdf'); + } elsif ($type eq 'gd') { + $self->{type} = 'GD'; + @validExt = ('png', 'gif'); + } else { + warn "Plots: Invalid image type $type."; + return; + } + + if ($ext) { + if (grep(/^$ext$/, @validExt)) { + $self->{ext} = $ext; + } else { + warn "Plots: Invalid image extension $ext."; + } + } else { + $self->{ext} = $validExt[0]; + } + + # Hardcopy: Tikz needs to use the 'pdf' extension and fallback to Tikz output if ext is 'html'. + if ($self->{pg}{displayMode} eq 'TeX' && ($self->{ext} eq 'html' || $self->{type} eq 'Tikz')) { + $self->{type} = 'Tikz'; + $self->{ext} = 'pdf'; + } + return; +} + +sub ext { + return (shift)->{ext}; +} + +# Return a copy of the tikz code (available after the image has been drawn). +# Set $plot->{tikzDebug} to 1 to just generate the tikzCode, and not create a graph. +sub tikz_code { + my $self = shift; + return $self->{tikzCode} && $self->{pg}{displayMode} =~ /HTML/ ? '
' . $self->{tikzCode} . '
' : ''; +} + +# Add functions to the graph. +sub value_to_sub { + my ($self, $formula, $var) = @_; + return sub { return $_[0]; } + if $formula eq $var; + unless (Value::isFormula($formula)) { + my $localContext = Parser::Context->current(\%main::context)->copy; + $localContext->variables->add($var => 'Real') unless $localContext->variables->get($var); + $formula = Value->Package('Formula()')->new($localContext, $formula); + } + + my $sub = $formula->perlFunction(undef, [$var]); + return sub { + my $x = shift; + my $y = Parser::Eval($sub, $x); + return defined $y ? $y->value : undef; + }; +} + +sub _add_function { + my ($self, $Fx, $Fy, $var, $min, $max, @rest) = @_; + $var = 't' unless $var; + $Fx = $var unless defined($Fx); + my %options = ( + x_string => ref($Fx) eq 'CODE' ? 'perl' : Value::isFormula($Fx) ? $Fx->string : $Fx, + y_string => ref($Fy) eq 'CODE' ? 'perl' : Value::isFormula($Fy) ? $Fy->string : $Fy, + variable => $var, + @rest + ); + $Fx = $self->value_to_sub($Fx, $var) unless ref($Fx) eq 'CODE'; + $Fy = $self->value_to_sub($Fy, $var) unless ref($Fy) eq 'CODE'; + + my $data = Plots::Data->new(name => 'function'); + $data->style( + color => 'default_color', + width => 1, + dashed => 0, + %options + ); + $data->set_function( + sub_x => $Fx, + sub_y => $Fy, + min => $min, + max => $max, + ); + $self->add_data($data); + return $data; +} + +# Format: Accepts both functions y = f(x) and parametric functions (x(t), y(t)). +# f(x) for x in using color:red and weight:3 and steps:15 +# x(t),y(t) for t in [a,b] using color:green and weight:1 and steps:35 +# (x(t),y(t)) for t in (a,b] using color:blue and weight:2 and steps:20 +sub parse_function_string { + my ($self, $fn) = @_; + unless ($fn =~ + /^(.+)for\s*(\w+)\s*in\s*([\(\[\<\{])\s*([^,\s]+)\s*,\s*([^,\s]+)\s*([\)\]\>\}])\s*(using)?\s*(.*)?$/) + { + warn "Error parsing function: $fn"; + return; + } + + my ($rule, $var, $start, $min, $max, $end, $options) = ($1, $2, $3, $4, $5, $6, $8); + if ($start eq '(') { $start = 'open_circle'; } + elsif ($start eq '[') { $start = 'closed_circle'; } + elsif ($start eq '{') { $start = 'arrow'; } + else { $start = 'none'; } + if ($end eq ')') { $end = 'open_circle'; } + elsif ($end eq ']') { $end = 'closed_circle'; } + elsif ($end eq '}') { $end = 'arrow'; } + else { $end = 'none'; } + + # Deal with the possibility of 'option1:value1, option2:value2, and option3:value3'. + $options =~ s/,\s*and/,/; + my %opts = ( + start_mark => $start, + end_mark => $end, + $options ? split(/\s*and\s*|\s*:\s*|\s*,\s*|\s*=\s*|\s+/, $options) : () + ); + + if ($rule =~ /^\s*[\(\[\<]\s*([^,]+)\s*,\s*([^,]+)\s*[\)\]\>]\s*$/ || $rule =~ /^\s*([^,]+)\s*,\s*([^,]+)\s*$/) { + my ($rule_x, $rule_y) = ($1, $2); + return $self->_add_function($rule_x, $rule_y, $var, $min, $max, %opts); + } + return $self->_add_function($var, $rule, $var, $min, $max, %opts); +} + +sub add_function { + my ($self, $f, @rest) = @_; + if ($f =~ /for.+in/) { + return @rest ? [ map { $self->parse_function_string($_); } ($f, @rest) ] : $self->parse_function_string($f); + } elsif (ref($f) eq 'ARRAY' && scalar(@$f) > 2) { + my @data; + for ($f, @rest) { + my ($g, @options) = @$_; + push(@data, + ref($g) eq 'ARRAY' + ? $self->_add_function($g->[0], $g->[1], @options) + : $self->_add_function(undef, $g, @options)); + } + return scalar(@data) > 1 ? \@data : $data[0]; + } + return ref($f) eq 'ARRAY' ? $self->_add_function($f->[0], $f->[1], @rest) : $self->_add_function(undef, $f, @rest); +} + +# Add a dataset to the graph. A dataset is basically a function in which the data +# is provided as a list of points, [$x1, $y1], [$x2, $y2], ..., [$xn, $yn]. +# Datasets can be used for points, arrows, lines, polygons, scatter plots, and so on. +sub _add_dataset { + my ($self, @points) = @_; + my $data = Plots::Data->new(name => 'dataset'); + while (@points) { + last unless ref($points[0]) eq 'ARRAY'; + $data->add(@{ shift(@points) }); + } + $data->style( + color => 'default_color', + width => 1, + @points + ); + + $self->add_data($data); + return $data; +} + +sub add_dataset { + my ($self, @data) = @_; + if (ref($data[0]) eq 'ARRAY' && ref($data[0][0]) eq 'ARRAY') { + return [ map { $self->_add_dataset(@$_); } @data ]; + } + return $self->_add_dataset(@data); +} + +sub _add_label { + my ($self, $x, $y, @options) = @_; + my $data = Plots::Data->new(name => 'label'); + $data->add($x, $y); + $data->style( + color => 'default_color', + fontsize => 'medium', + orientation => 'horizontal', + h_align => 'center', + v_align => 'middle', + label => '', + @options + ); + + $self->add_data($data); + return $data; +} + +sub add_label { + my ($self, @labels) = @_; + return ref($labels[0]) eq 'ARRAY' ? [ map { $self->_add_label(@$_); } @labels ] : $self->_add_label(@labels); +} + +# Fill regions only work with GD and are ignored in TikZ images. +sub _add_fill_region { + my ($self, $x, $y, $color) = @_; + my $data = Plots::Data->new(name => 'fill_region'); + $data->add($x, $y); + $data->style(color => $color || 'default_color'); + $self->add_data($data); + return $data; +} + +sub add_fill_region { + my ($self, @regions) = @_; + return + ref($regions[0]) eq 'ARRAY' + ? [ map { $self->_add_fill_region(@$_); } @regions ] + : $self->_add_fill_region(@regions); +} + +sub _add_stamp { + my ($self, $x, $y, @options) = @_; + my $data = Plots::Data->new(name => 'stamp'); + $data->add($x, $y); + $data->style( + color => 'default_color', + size => 4, + symbol => 'closed_circle', + @options + ); + $self->add_data($data); + return $data; +} + +sub add_stamp { + my ($self, @stamps) = @_; + return ref($stamps[0]) eq 'ARRAY' ? [ map { $self->_add_stamp(@$_); } @stamps ] : $self->_add_stamp(@stamps); +} + +# Output the image based on a configurable type: +sub draw { + my $self = shift; + my $type = $self->{type}; + + my $image; + if ($type eq 'Tikz') { + $image = Plots::Tikz->new($self); + } elsif ($type eq 'JSX') { + $image = Plots::JSX->new($self); + } elsif ($type eq 'Plotly') { + $image = Plots::Plotly->new($self); + } elsif ($type eq 'GD') { + $image = Plots::GD->new($self); + } else { + warn "Undefined image type: $type"; + return; + } + return $image->draw; +} + +1; diff --git a/lib/Plots/Plotly.pm b/lib/Plots/Plotly.pm new file mode 100644 index 000000000..629d6f574 --- /dev/null +++ b/lib/Plots/Plotly.pm @@ -0,0 +1,104 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2023 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +=head1 DESCRIPTION + +This is the code that takes a C and creates a Plotly.js graph of the plot. + +See L for more details. + +=cut + +package Plots::Plotly; + +use strict; +use warnings; + +sub new { + my ($class, $pgplot) = @_; + + $pgplot->insert_js('node_modules/plotly.js-dist-min/plotly.min.js'); + + return bless { pgplot => $pgplot, plots => [] }, $class; +} + +sub pgplot { + my $self = shift; + return $self->{pgplot}; +} + +sub HTML { + my $self = shift; + my $pgplot = $self->pgplot; + my $axes = $pgplot->axes; + my $grid = $axes->grid; + my $name = $pgplot->get_image_name =~ s/-/_/gr; + my $title = $axes->style('title'); + my $plots = ''; + my ($xmin, $ymin, $xmax, $ymax) = $axes->bounds; + my ($height, $width) = $pgplot->size; + my $style = 'border: solid 2px; display: inline-block; margin: 5px; text-align: center;'; + + $title = "$title" if $title; + for (@{ $self->{plots} }) { + $plots .= $_; + } + + return <$title +
+ + +END_HTML +} + +sub draw { + my $self = shift; + my $pgplot = $self->pgplot; + + # Plot Data + for my $data ($pgplot->data('function', 'dataset')) { + $data->gen_data; + + my $x_points = join(',', $data->x); + my $y_points = join(',', $data->y); + my $plot = <{plots} }, $plot); + } + + return $self->HTML; +} + +1; diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm new file mode 100644 index 000000000..b9a884f49 --- /dev/null +++ b/lib/Plots/Tikz.pm @@ -0,0 +1,287 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2023 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +=head1 DESCRIPTION + +This is the code that takes a C and creates the tikz code for generation. + +See L for more details. + +=cut + +package Plots::Tikz; + +use strict; +use warnings; + +sub new { + my ($class, $pgplot) = @_; + my $image = LaTeXImage->new; + $image->environment('tikzpicture'); + $image->svgMethod($main::envir{latexImageSVGMethod} // 'dvisvgm'); + $image->convertOptions($main::envir{latexImageConvertOptions} // { input => {}, output => {} }); + $image->ext($pgplot->ext); + $image->tikzLibraries('arrows.meta,plotmarks'); + $image->texPackages(['pgfplots']); + $image->addToPreamble('\pgfplotsset{compat=1.18}\usepgfplotslibrary{fillbetween}'); + + return bless { image => $image, pgplot => $pgplot, colors => {} }, $class; +} + +sub pgplot { + my $self = shift; + return $self->{pgplot}; +} + +sub im { + my $self = shift; + return $self->{image}; +} + +sub get_color { + my ($self, $color) = @_; + return '' if $self->{colors}{$color}; + my ($r, $g, $b) = @{ $self->pgplot->colors($color) }; + $self->{colors}{$color} = 1; + return "\\definecolor{$color}{RGB}{$r,$g,$b}\n"; +} + +sub configure_axes { + my $self = shift; + my $pgplot = $self->pgplot; + my $axes = $pgplot->axes; + my $grid = $axes->grid; + my ($xmin, $ymin, $xmax, $ymax) = $axes->bounds; + my ($axes_height, $axes_width) = $pgplot->size; + my $show_grid = $axes->style('show_grid'); + my $xmajor = $show_grid && $grid->{xmajor} ? 'true' : 'false'; + my $xminor_num = $show_grid && $grid->{xmajor} ? $grid->{xminor} : 0; + my $xminor = $xminor_num > 0 ? 'true' : 'false'; + my $ymajor = $show_grid && $grid->{ymajor} ? 'true' : 'false'; + my $yminor_num = $show_grid && $grid->{ymajor} ? $grid->{yminor} : 0; + my $yminor = $yminor_num > 0 ? 'true' : 'false'; + my $xticks = join(',', @{ $grid->{xticks} }); + my $yticks = join(',', @{ $grid->{yticks} }); + my $grid_color = $axes->style('grid_color'); + my $grid_color2 = $self->get_color($grid_color); + my $grid_alpha = $axes->style('grid_alpha'); + my $grid_style = $axes->style('grid_style'); + my $xlabel = $axes->xaxis('label'); + my $axis_x_line = $axes->xaxis('location'); + my $ylabel = $axes->yaxis('label'); + my $axis_y_line = $axes->yaxis('location'); + my $title = $axes->style('title'); + my $axis_on_top = $axes->style('axis_on_top') ? "axis on top,\n\t\t\t" : ''; + my $hide_x_axis = ''; + my $hide_y_axis = ''; + my $xaxis_plot = ($xmin <= 0 && $xmax >= 0) ? "\\path[name path=xaxis] ($xmin, 0) -- ($xmax,0);\n" : ''; + + unless ($axes->xaxis('visible')) { + $xlabel = ''; + $hide_x_axis = + "\n\t\t\tx axis line style={draw=none},\n" + . "\t\t\tx tick style={draw=none},\n" + . "\t\t\txticklabel=\\empty,"; + } + unless ($axes->yaxis('visible')) { + $ylabel = ''; + $hide_y_axis = + "\n\t\t\ty axis line style={draw=none},\n" + . "\t\t\ty tick style={draw=none},\n" + . "\t\t\tyticklabel=\\empty,"; + } + my $tikzCode = <style('color') || 'default_color'; + my $width = $data->style('width') || 1; + my $linestyle = $data->style('linestyle') || 'solid'; + my $marks = $data->style('marks') || 'none'; + my $mark_size = $data->style('mark_size') || 0; + my $start = $data->style('start_mark') || 'none'; + my $end = $data->style('end_mark') || 'none'; + my $name = $data->style('name') || ''; + my $fill = $data->style('fill') || 'none'; + my $fill_color = $data->style('fill_color') || 'default_color'; + my $fill_opacity = $data->style('fill_opacity') || 0.5; + my $tikzOpts = $data->style('tikzOpts') || ''; + + if ($start =~ /circle/) { + $start = '{Circle[sep=-1.196825pt -1.595769' . ($start eq 'open_circle' ? ', open' : '') . ']}'; + } elsif ($start eq 'arrow') { + $start = '{Latex}'; + } else { + $start = ''; + } + if ($end =~ /circle/) { + $end = '{Circle[sep=-1.196825pt -1.595769' . ($end eq 'open_circle' ? ', open' : '') . ']}'; + } elsif ($end eq 'arrow') { + $end = '{Latex}'; + } else { + $end = ''; + } + my $end_markers = ($start || $end) ? ", $start-$end" : ''; + $marks = { + closed_circle => '*', + open_circle => 'o', + plus => '+', + times => 'x', + bar => '|', + dash => '-', + asterisk => 'asterisk', + star => 'star', + oplus => 'oplus', + otimes => 'otimes', + diamond => 'diamond', + none => '', + }->{$marks}; + $marks = $marks ? $mark_size ? ", mark=$marks, mark size=${mark_size}px" : ", mark=$marks" : ''; + $linestyle = $linestyle eq 'none' ? ', only marks' : ', ' . ($linestyle =~ s/_/ /gr); + if ($fill eq 'self') { + $fill = ", fill=$fill_color, fill opacity=$fill_opacity"; + } else { + $fill = ''; + } + $name = ", name path=$name" if $name; + $tikzOpts = ", $tikzOpts" if $tikzOpts; + + return "color=$color, line width=${width}pt$marks$linestyle$end_markers$fill$name$tikzOpts"; +} + +sub draw { + my $self = shift; + my $pgplot = $self->pgplot; + + # Reset colors just in case. + $self->{colors} = {}; + + # Add Axes + my $tikzCode = $self->configure_axes; + + # Plot Data + for my $data ($pgplot->data('function', 'dataset')) { + $data->gen_data; + my $n = $data->size; + my $color = $data->style('color') || 'default_color'; + my $fill = $data->style('fill') || 'none'; + my $fill_color = $data->style('fill_color') || 'default_color'; + my $tikzData = join(' ', map { '(' . $data->x($_) . ',' . $data->y($_) . ')'; } (0 .. $n - 1)); + my $tikzOpts = $self->get_plot_opts($data); + $tikzCode .= $self->get_color($fill_color) unless $fill eq 'none'; + $tikzCode .= $self->get_color($color) . "\\addplot[$tikzOpts] coordinates {$tikzData};\n"; + + unless ($fill eq 'none' || $fill eq 'self') { + my $opacity = $data->style('fill_opacity') || 0.5; + my $fill_range = $data->style('fill_range') || ''; + my $name = $data->style('name') || ''; + $opacity *= 100; + if ($fill_range) { + my ($min_fill, $max_fill) = split(',', $fill_range); + $fill_range = ", soft clip={domain=$min_fill:$max_fill}"; + } + $tikzCode .= "\\addplot[$fill_color!$opacity] fill between[of=$name and $fill$fill_range];\n"; + } + } + + # Stamps + for my $stamp ($pgplot->data('stamp')) { + my $mark = { + closed_circle => '*', + open_circle => 'o', + plus => '+', + times => 'x', + bar => '|', + dash => '-', + asterisk => 'asterisk', + star => 'star', + oplus => 'oplus', + otimes => 'otimes', + diamond => 'diamond', + none => '', + }->{ $stamp->style('symbol') }; + my $color = $stamp->style('color') || 'default_color'; + my $x = $stamp->x(0); + my $y = $stamp->y(0); + my $r = $stamp->style('radius') || 4; + $tikzCode .= $self->get_color($color) + . "\\addplot[$color, mark=$mark, mark size=${r}pt, only marks] coordinates {($x,$y)};\n"; + } + + # Labels + for my $label ($pgplot->data('label')) { + my $str = $label->style('label'); + my $x = $label->x(0); + my $y = $label->y(0); + my $color = $label->style('color') || 'default_color'; + my $fontsize = $label->style('fontsize') || 'medium'; + my $orientation = $label->style('orientation') || 'horizontal'; + my $tikzOpts = $label->style('tikzOpts') || ''; + my $h_align = $label->style('h_align') || 'center'; + my $v_align = $label->style('v_align') || 'middle'; + my $anchor = $v_align eq 'top' ? 'north' : $v_align eq 'bottom' ? 'south' : ''; + $str = { + tiny => '\tiny ', + small => '\small ', + medium => '', + large => '\large ', + giant => '\Large ', + }->{$fontsize} + . $str; + $anchor .= $h_align eq 'left' ? ' west' : $h_align eq 'right' ? ' east' : ''; + $tikzOpts = $tikzOpts ? "$color, $tikzOpts" : $color; + $tikzOpts = "anchor=$anchor, $tikzOpts" if $anchor; + $tikzOpts = "rotate=90, $tikzOpts" if $orientation eq 'vertical'; + $tikzCode .= $self->get_color($color) . "\\node[$tikzOpts] at (axis cs: $x,$y) {$str};\n"; + } + $tikzCode .= '\end{axis}' . "\n"; + + $pgplot->{tikzCode} = $tikzCode; + $self->im->tex($tikzCode); + return $pgplot->{tikzDebug} ? '' : $self->im->draw; +} + +1; diff --git a/macros/core/PGbasicmacros.pl b/macros/core/PGbasicmacros.pl index 06c443631..f7ba8b97a 100644 --- a/macros/core/PGbasicmacros.pl +++ b/macros/core/PGbasicmacros.pl @@ -2909,8 +2909,21 @@ sub image { ); next; } + if (ref $image_item eq 'Plots::Plot') { + # Update image size as needed. + $image_item->{size}->[0] = $width if $out_options{width}; + $image_item->{size}->[1] = $height if $out_options{height}; + + if ($image_item->ext eq 'html') { + push(@output_list, $image_item->draw); + next; + } + } $image_item = insertGraph($image_item) - if (ref $image_item eq 'WWPlot' || ref $image_item eq 'PGlateximage' || ref $image_item eq 'PGtikz'); + if (ref $image_item eq 'WWPlot' + || ref $image_item eq 'Plots::Plot' + || ref $image_item eq 'PGlateximage' + || ref $image_item eq 'PGtikz'); my $imageURL = alias($image_item) // ''; $imageURL = ($envir{use_site_prefix}) ? $envir{use_site_prefix} . $imageURL : $imageURL; my $out = ""; diff --git a/macros/graph/VectorField2D.pl b/macros/graph/VectorField2D.pl index 3cc8a2d7d..369e749ae 100644 --- a/macros/graph/VectorField2D.pl +++ b/macros/graph/VectorField2D.pl @@ -109,7 +109,7 @@ sub VectorField2D { ); my $gr = $options{graphobject}; - unless (ref($gr) eq 'WWPlot') { + unless (ref($gr) eq 'WWPlot' || ref($gr) eq 'Plots::Plot') { warn 'VectorField2D: Invalid graphobject provided.'; return; } @@ -129,6 +129,9 @@ sub VectorField2D { return; } + # Takes to long to render this field using Tikz, force GD output. + $gr->image_type('GD') if (ref($gr) eq 'Plots::Plot'); + # Generate plot data my $dx = ($options{xmax} - $options{xmin}) / $options{xsamples}; my $dy = ($options{ymax} - $options{ymin}) / $options{ysamples}; diff --git a/macros/graph/plots.pl b/macros/graph/plots.pl new file mode 100644 index 000000000..1ea9717a3 --- /dev/null +++ b/macros/graph/plots.pl @@ -0,0 +1,416 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2023 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +=head1 NAME + +plots.pl - A macro to create dynamic graphs to include in PG problems. + +=head1 DESCRIPTION + +This macro creates a Plots 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), C (using jsxgraph), C +(using Plotly.js), and the legacy C graphics format are available. + +Note, not all graph settings apply to all formats. The settings are designed +based off of TikZ and PGFplots, and are adapted to other formats when possible. + +=head1 USAGE + +First create a Plots object: + + loadMacros('plots.pl'); + $plot = Plot(); + +Configure the L: + + $plot->axes->xaxis( + min => 0, + max => 10, + ticks => [0, 2, 4, 6, 8, 10], + label => '\(t\)', + ); + $plot->axes->yaxis( + min => 0, + max => 500, + ticks => [0, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500], + label => '\(h(t)\)' + ); + $plot->axes->style(title => 'Height of an object as a function of time.'); + +Add a function and other objects to the plot. + + $plot->add_function('-16t^2 + 80t + 384', 't', 0, 8, color => blue, width => 3); + +Insert the graph into the problem. + + BEGIN_PGML + [! Plot of a quadratic function !]{$plot}{500} + END_PGML + +=head1 PLOT ELEMENTS + +A plot consists of multiple L objects, which define datasets, functions, +and labels to add to the graph. Data objects should be created though the Plots object, +but can be access directly if needed + +=head2 DATASETS + +The core plot element is a dataset, which is a collection of points and options +to plot the data. Datasets are added to a plot via C<< $plot->add_dataset >>, and +can be added individually, or multiple at once as shown: + + # Add a single dataset + $plot->add_dataset([$x1, $y1], [$x2, $y2], ..., [$xn, $yn], @options)> + # Add multiple datasets with single call + $plot->add_dataset( + [[$x11, $y11], [$x12, $y12], ..., [$x1n, $y1n], @options1], + [[$x21, $y21], [$x22, $y22], ..., [$x2m, $y2m], @options2], + ... + ); + +For example, add a red line segment from (2,3) to (5,7): + + $plot->add_dataset([2, 3], [5, 7], color => 'red', width => 2); + +Add multiple arrows by setting the C (or C) of the dataset. + + $plot->add_dataset( + [[0, 0], [2,3], color => 'green', end_mark => 'arrow'], + [[2, 3], [4,-1], color => 'blue', end_mark => 'arrow'], + [[0, 0], [4, -1], color => 'red', end_mark => 'arrow'], + ); + +If needed, the C<< $plot->add_dataset >> method returns the L object +(or array of Data objects) which can be manipulated directly. + + $data = $plot->add_dataset(...); + +=head2 PLOT FUNCTIONS + +Functions can be used to generate a dataset to plot. Similar to datasets +functions can be added individually or multiple at once: + + # Add a single function + $plot->add_function($function, $variable, $min, $max, @options) + # Add multiple functions + $plot->add_function( + [$function1, $variable1, $min1, $max1, @options1], + [$function2, $variable2, $min2, $max2, @options2], + ... + ); + +This method can be used to add both single variable functions and +parametric functions (an array of two functions) to the graph. + + # Add the function y = x^2 to the plot. + $plot->add_function('x^2', 'x', -5, 5); + # Add a parametric circle of radius 5 to the plot. + $plot->add_function(['5cos(t)', '5sin(t)'], 't', 0, 2*pi); + +Functions can be defined using strings (which are turned into MathObjects), +MathObjects, or perl subroutines: + + # Add a function from a predefined MathObject. + $f = Compute("$a x^2 + $b x + $c"); + $plot->add_function($f, 'x', -5, 5, width => 3); + # Define a function using a perl subroutine. + # The variable is undefined since it is not used. + $plot->add_function( + [ sub { return $_[0]**2; }, sub { return $_[0]; } ], + undef, + -5, + 5, + color => 'green', + width => 2 + ); + +Functions can also be added using function strings. Function strings are of the form: + + "$function for $variable in <$min,$max> using option1:value1 and option2:value2" + +This can be used to add either single variable functions or parametric functions: + + 'x^2 for x in [-5,5) using color:red, weight:3 and steps:15' + '(5cos(t), 5sin(t)) for t in <2,2pi> using color:blue, weight:2 and steps:20' + +The interval end points configure if an open_circle, C<(> or C<)>, closed_circle, C<[> or C<]>, +arrow, C<{> or C<}>, or no marker, C<< < >> or C<< > >>, are added to the ends of the plot. Options are +listed in the form C and can be separated by either commas or the word C. +Multiple functions can be added at once using a list of function strings, which can be useful +for creating piecewise functions. + + # Add two single variable functions and a parametric function to the graph. + $plot->add_function( + 'x + 2 for x in [-4, 4] using color:blue and weight:3', + 'x^2 for x in {-4, 4} using color:red and weight:3', + '(2cos(t), 2sin(t)) for t in <0, 2pi> using color:green and weight:2' + ); + # Add a piecewise function to the graph. + $plot->add_function( + '-3-x for x in {-5,-2.5)', + 'x^2-4 for x in [-2.5,2.5)', + '8-2x for x in [2.5,5}' + ); + +=head2 DATASET OPTIONS + +The following are the options that can be used to configure how datasets and functions are plotted. + +=over 5 + +=item color + +The color of the plot. Default: 'default_color' + +=item width + +The line width of the plot. Default: 1 + +=item linestyle + +Linestyle can be one of 'solid', 'dashed', 'dotted', 'densely dashed', +'loosely dashed', 'densely dotted', 'loosely dotted', or 'none'. If set +to 'none', only the points are shown (see marks for point options) For +convince underscores can also be used, such as 'densely_dashed'. +Default: 'solid' + +=item marks + +Configures the symbol used for plotting the points in the dataset. Marks +can be one of 'none', 'open_circle', 'closed_circle', 'plus', 'times', +'dash', 'bar', 'asterisk', 'star', 'oplus', 'otimes', or 'diamond'. +Default: 'none' + +=item mark_size + +Configure the size of the marks (if shown). The size is a natural number, +and represents the point (pt) size of the mark. If the size is 0, the +default size is used. Default: 0 + +=item start_mark + +Place a mark at the start (left end) of the plot. This can be one of +'none', 'closed_circle', 'open_circle', or 'arrow'. Default: 'none' + +=item end_mark + +Place a mark at the end (right end) of the plot. This can be one of +'none', 'closed_circle', 'open_circle', or 'arrow'. Default: 'none' + +=item name + +The name assigned to the dataset to reference it for filling (see below). + +=item fill + +Sets the fill method to use. If set to 'none', no fill will be added. +If set to 'self', the object fills within itself, best used with closed +datasets. If set to 'xaxis', this will fill the area between the curve +and the x-axis. If set to another non-empty string, this is the name of the +other dataset to fill against. + +The following creates a filled rectangle: + + $plot->add_dataset([1, 1], [2, 1], [2, 2], [1, 2], [1, 1], + color => 'blue', + width => 1.5, + fill => 'self', + fill_color => 'green', + fill_opacity => 0.1, + ); + +The following fills the area between the two curves y = 4 - x^2 and y = x^2 - 4, +and only fills in the area between x=-2 and x=2: + + $plot->add_function('4 - x^2', 'x', -3, 3, + color => 'blue', + name => 'A' + ); + $plot->add_function('x^2 - 4', 'x', -3, 3, + color => 'blue', + name => 'B', + fill => 'A', + fill_opacity => 0.2, + fill_range => '-2,2', + fill_color => 'green', + ); + +=item fill_color + +The color used when filling the region. Default: 'default_color' + +=item fill_opacity + +A number between 0 and 1 giving the opacity of the fill. Default: 0.5 + +=item fill_range + +This is a string that contains two number separated by a comma, C<"$min,$max">. This gives +the domain of the fill when filling between two curves or the x-axis. Useful to only fill +a piece of the curve. Default: '' + +=item steps + +This defines the number of points to generate for a dataset from a function. +Default: 20. + +=item tikzOpts + +Additional pgfplots C<\addplot> options to be added to the tikz output. + +=back + +=head2 LABELS + +Labels can be added to the graph using the C<< $plot->add_label >> method. +Similar to datasets this can be added individually or multiple at once. + + # Add a label at the point ($x, $y). + $plot->add_label($x, $y, label => $label, @options)> + # Add multiple labels at once. + $plot->add_label( + [$x1, $y1, label => $label1, @options1], + [$x2, $y2, label => $label2, @options2], + ... + ); + +Labels can be configured using the following options: + +=over 5 + +=item label + +The text to be added to the plot. + +=item color + +The color of the label. Default: 'default_color' + +=item fontsize + +The size of the label used in GD output. This can be one of +'tiny', 'small', 'medium', 'large', or 'giant'. Default: 'medium' + +=item orientation + +The orientation of the font in GD output. Can be one of 'vertical' or 'horizontal'. +Default: 'horizontal' + +=item h_align + +The horizontal alignment of the text relative to the position of the label, +that states which end of the label is placed at the label's position. +Can be one of 'right', 'center', or 'left'. Default: 'center' + +=item v_align + +The vertical alignment of the text relative to the position of the label, +that states which end of the label is placed at the label's position. +Can be one of 'top', 'middle', or 'bottom'. Default: 'middle' + +=item tikzOpts + +Additional TikZ options to be used when adding the label using TikZ output via C<\node>. + +=back + +=head2 STAMPS + +Stamps are a single point with a mark drawn at the given point. +Stamps can be added individually or multiple at once: + + # Add a single stamp. + $plot->add_stamp($x1, $y1, symbol => $symbol, color => $color, radius => $radius); + # Add Multple stamps. + $plot->add_stamp( + [$x1, $y1, symbol => $symbol1, color => $color1, radius => $radius1], + [$x2, $y2, symbol => $symbol2, color => $color2, radius => $radius2], + ... + ); + +Stamps are here for backwards compatibility with WWplot and GD output, and are +equivalent to creating a dataset with one point when not using GD output (with +the small difference that stamps are added after all other datasets have been added). + +=head2 FILL REGIONS + +Fill regions define a point which GD will fill with a color until it hits a boundary curve. +This is only here for backwards comparability with WWplot and GD output. This will not +work with TikZ output, instead using the fill methods mentioned above. + + # Add a single fill region. + $plot->add_fill_region($x1, $y1, $color); + # Add multiple fill regions. + $plot->add_fill_region( + [$x1, $y1, $color1], + [$x2, $y2, $color2], + ... + ); + +=head2 COLORS + +Colors are referenced by color names. The default color names, and their RGB definition are: + + Color Name Red Grn Blu + background_color 255 255 255 + default_color 0 0 0 + white 255 255 255 + black 0 0 0 + red 255 0 0 + green 0 255 0 + blue 0 0 255 + yellow 255 255 0 + orange 255 100 0 + gray 180 180 180 + nearwhite 254 254 254 + +New colors can be added, or existing colors can be modified, using the C<< $plot->add_color >> method. +Colors can be added individually or multiple using a single call. + + # Add a single color. + $plot->add_color($color_name, $red, $green, $blue); + # Add multiple colors. + $plot->add_color( + [$color_name1, $red1, $green1, $blue1], + [$color_name2, $red2, $green2, $blue2], + ... + ); + +=head1 TIKZ DEBUGGING + +When using Tikz output, the pgfplots code used to create the plot is stored in C<< $plot->{tikzCode} >>, +after the image has been drawn (added to the problem with insertGraph). In addition there is a special +debugging option C<< $plot->{tikzDebug} >>, which if set will bypass building the graph with latex, allowing +access to the tikz code (useful if there is an error in generating the plot). Last the method +C<< $plot->tikz_code >> will return the code in pre tags to format inside a problem. For instance to view +the tikz code of a graph that is failing to build use: + + $plot->{tikzDebug} = 1; + $image = insertGraph($plot); + $tikzCode = $plot->tikz_code; + BEGIN_PGML + [$tikzCode]* + END_PGML + +=cut + +BEGIN { + strict->import; +} + +sub _plots_init { } + +sub Plot { Plots::Plot->new($main::PG, @_); } diff --git a/macros/graph/unionImage.pl b/macros/graph/unionImage.pl index 9dcc4e5b6..f4438dc59 100644 --- a/macros/graph/unionImage.pl +++ b/macros/graph/unionImage.pl @@ -66,12 +66,12 @@ sub Image { my $TeX; ($image, $ilink) = @{$image} if (ref($image) eq "ARRAY"); $ilink = $ilink // ''; - $image = alias(insertGraph($image)) if (ref($image) eq "WWPlot"); - $image = alias($image) unless ($image =~ m!^(/|https?:)!i); # see note + $image = alias(insertGraph($image)) if (ref($image) eq "WWPlot" || ref($image) eq 'Plots::Plot'); + $image = alias($image) unless ($image =~ m!^(/|https?:)!i); # see note if ($ilink) { $ilink = alias(insertGraph($ilink)) if (ref($ilink) eq "WWPlot"); - $ilink = alias($ilink) unless ($ilink =~ m!^(/|https?:)!i); # see note + $ilink = alias($ilink) unless ($ilink =~ m!^(/|https?:)!i); # see note } else { $ilink = $image; }