From 2817929db841d5cf95cd9ddef8dcb78f4c962eee Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Sun, 9 Apr 2023 23:50:02 -0600 Subject: [PATCH 1/5] Add PGplot, a method for generating graphs. PGplot is a new method for generating dynamic graphs and can be used as a replacement for WWPlot and PGgraphmacros.pl. PGplot is a method to create a plot object and store information about a plot in multiple data objects. Since PGplot just stores data about the plot, the data can then be used to create multiple different outputs. Currently only TikZ pgfplots and GD (for testing) are supported. --- macros/core/PGbasicmacros.pl | 5 +- macros/graph/PGplot.pl | 766 ++++++++++++++++++++++++++++++++++ macros/graph/PGplot/Axes.pl | 342 +++++++++++++++ macros/graph/PGplot/Data.pl | 230 ++++++++++ macros/graph/PGplot/GD.pl | 383 +++++++++++++++++ macros/graph/PGplot/Tikz.pl | 289 +++++++++++++ macros/graph/VectorField2D.pl | 5 +- macros/graph/unionImage.pl | 6 +- 8 files changed, 2021 insertions(+), 5 deletions(-) create mode 100644 macros/graph/PGplot.pl create mode 100644 macros/graph/PGplot/Axes.pl create mode 100644 macros/graph/PGplot/Data.pl create mode 100644 macros/graph/PGplot/GD.pl create mode 100644 macros/graph/PGplot/Tikz.pl diff --git a/macros/core/PGbasicmacros.pl b/macros/core/PGbasicmacros.pl index 06c4436314..89fde80139 100644 --- a/macros/core/PGbasicmacros.pl +++ b/macros/core/PGbasicmacros.pl @@ -2910,7 +2910,10 @@ sub image { 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 'PGplot' + || 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/PGplot.pl b/macros/graph/PGplot.pl new file mode 100644 index 0000000000..2602f86f54 --- /dev/null +++ b/macros/graph/PGplot.pl @@ -0,0 +1,766 @@ +################################################################################ +# 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 + +PGplot.pl - An object to create dynamic graphs to include in PG problems. + +=head1 DESCRIPTION + +This macro creates a PGplot 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 the legacy GD graphics format and TikZ (using pgfplots) +are available. + +=head1 USAGE + +First create a PGplot object: + + loadMacros('PGplot.pl'); + $plot = PGplot(); + +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 + [@ image(insertGraph($plot), width => 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 PGplot 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-Eadd_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-Eadd_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-Eadd_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-Eadd_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-E{tikzCode}>, +after the image has been drawn (added to the problem with insertGraph). In addition there is a special +debugging option C<$plot-E{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-Etikz_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; +} + +loadMacros('MathObjects.pl', 'PGplot/Axes.pl', 'PGplot/Data.pl', 'PGplot/GD.pl', 'PGplot/Tikz.pl'); + +sub _PGplot_init { } + +sub PGplot { PGplot->new(@_); } + +package PGplot; + +sub new { + my $class = shift; + my $size = $main::envir{onTheFlyImageSize} || 500; + + my $self = { + imageName => {}, + type => 'Tikz', + ext => 'svg', + size => [ $size, $size ], + axes => PGplot::Axes->new, + colors => {}, + data => [], + @_ + }; + + bless $self, $class; + $self->color_init; + return $self; +} + +sub colors { + my ($self, $color) = @_; + return defined($color) ? $self->{colors}{$color} : $self->{colors}; +} + +sub _add_color { + my ($self, $color, $r, $g, $b) = @_; + $self->{'colors'}{$color} = [ $r, $g, $b ]; + return; +} + +sub add_color { + my $self = shift; + if (ref($_[0]) eq 'ARRAY') { + for (@_) { $self->_add_color(@$_); } + } else { + $self->_add_color(@_); + } + 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->{size} } : $self->{size}; +} + +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} = $main::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 'tikz') { + $self->{type} = 'Tikz'; + @validExt = ('svg', 'png', 'pdf'); + } elsif ($type eq 'gd') { + $self->{type} = 'GD'; + @validExt = ('png', 'gif'); + } else { + warn "PGplot: Invalid image type $type."; + return; + } + + if ($ext) { + if (grep(/^$ext$/, @validExt)) { + $self->{ext} = $ext; + } else { + warn "PGplot: Invalid image extension $ext."; + } + } else { + $self->{ext} = $validExt[0]; + } + return; +} + +# Tikz needs to use pdf for hardcopy generation. +sub ext { + my $self = shift; + return 'pdf' if ($self->{type} eq 'Tikz' && $main::displayMode eq 'TeX'); + return $self->{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} && $main::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 = PGplot::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 = PGplot::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 = shift; + if (ref($_[0]) eq 'ARRAY' && ref($_[0]->[0]) eq 'ARRAY') { + return [ map { $self->_add_dataset(@$_); } @_ ]; + } + return $self->_add_dataset(@_); +} + +sub _add_label { + my ($self, $x, $y, @options) = @_; + my $data = PGplot::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 = shift; + return ref($_[0]) eq 'ARRAY' ? [ map { $self->_add_label(@$_); } @_ ] : $self->_add_label(@_); +} + +# Fill regions only work with GD and are ignored in TikZ images. +sub _add_fill_region { + my ($self, $x, $y, $color) = @_; + my $data = PGplot::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 = shift; + return ref($_[0]) eq 'ARRAY' ? [ map { $self->_add_fill_region(@$_); } @_ ] : $self->_add_fill_region(@_); +} + +sub _add_stamp { + my ($self, $x, $y, @options) = @_; + my $data = PGplot::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 = shift; + return ref($_[0]) eq 'ARRAY' ? [ map { $self->_add_stamp(@$_); } @_ ] : $self->_add_stamp(@_); +} + +# Output the image based on a configurable type: +sub draw { + my $self = shift; + my $type = $self->{type}; + + my $image; + if ($type eq 'GD') { + $image = PGplot::GD->new($self); + } elsif ($type eq 'Tikz') { + $image = PGplot::Tikz->new($self); + } else { + warn "Undefined image type: $type"; + return; + } + return $image->draw; +} + +1; diff --git a/macros/graph/PGplot/Axes.pl b/macros/graph/PGplot/Axes.pl new file mode 100644 index 0000000000..07c7b5ae90 --- /dev/null +++ b/macros/graph/PGplot/Axes.pl @@ -0,0 +1,342 @@ +################################################################################ +# 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 + +PGplot/Axes.pl - Object used with PGplot to store data about a plot's title and axes. + +=head1 DESCRIPTION + +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 L object using C<$plot-Eaxes>. +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-Eaxes-Ebounds> 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 + +BEGIN { + strict->import; +} + +sub _Axes_init { } + +package PGplot::Axes; + +sub new { + my $class = shift; + my $self = { + xaxis => {}, + yaxis => {}, + styles => { + title => '', + grid_color => 'gray', + grid_style => 'solid', + grid_alpha => 40, + show_grid => 1, + }, + @_ + }; + + bless $self, $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 = shift; + return $self->axis('xaxis', @_); +} + +sub yaxis { + my $self = shift; + return $self->axis('yaxis', @_); +} + +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/macros/graph/PGplot/Data.pl b/macros/graph/PGplot/Data.pl new file mode 100644 index 0000000000..f261748e7f --- /dev/null +++ b/macros/graph/PGplot/Data.pl @@ -0,0 +1,230 @@ +################################################################################ +# 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 + +Data.pl - Base data class for PGplot elements (functions, labels, etc). + +=head1 DESCRIPTION + +This is a data class to hold data about the different types of elements +that can be added to a PGplot graph. This is a hash with some helper methods. +Data objects are created and modified using the L 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-Ename> + +Sets, C<$data-Ename($string)>, or gets C<$data-Ename> the name of the data object. + +=item C<$data-Eadd> + +Adds a single data point, C<$data-Eadd($x, $y)>, or adds multiple data points, +C<$data-Eadd([$x1, $y1], [$x2, $y2], ..., [$xn, $yn])>. + +=item C<$data-Eset_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-Egen_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-Esize> + +Returns the current number of points being stored. + +=item C<$data-Ex> and C<$data-Ey> + +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-Ex($n)>. + +=item C<$data-Estyle> + +Sets or gets style information. Use C<$data-Estyle($name)> to get the style value of a single +style name. C<$data-Estyle> 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 + +BEGIN { + strict->import; +} + +sub _Data_init { } + +package PGplot::Data; + +sub new { + my $class = shift; + my $self = { + name => '', + x => [], + y => [], + function => {}, + styles => {}, + @_ + }; + + bless $self, $class; + return $self; +} + +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 = shift; + $self->{function} = { + sub_x => sub { return $_[0]; }, + sub_y => sub { return $_[0]; }, + min => -5, + max => 5, + @_ + }; + $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 = shift; + if (ref($_[0]) eq 'ARRAY') { + for (@_) { $self->_add(@$_); } + } else { + $self->_add(@_); + } + return; +} + +1; diff --git a/macros/graph/PGplot/GD.pl b/macros/graph/PGplot/GD.pl new file mode 100644 index 0000000000..c6b6a2bc2d --- /dev/null +++ b/macros/graph/PGplot/GD.pl @@ -0,0 +1,383 @@ +################################################################################ +# 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. +################################################################################ + +BEGIN { + strict->import; +} + +sub _GD_init { } + +package PGplot::GD; + +sub new { + my ($class, $pgplot) = @_; + my $self = { + image => '', + pgplot => $pgplot, + position => [ 0, 0 ], + colors => {}, + }; + bless $self, $class; + + $self->{image} = new GD::Image($pgplot->size); + return $self; +} + +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->size)[0] / ($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->size)[1] / ($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 scalar(@_) > 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 = new GD::Polygon; + $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 $size = $pgplot->size; + + # 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, $size->[0] - 1, $size->[1] - 1, $self->color('black')); + + return $pgplot->ext eq 'gif' ? $self->im->gif : $self->im->png; +} + +1; diff --git a/macros/graph/PGplot/Tikz.pl b/macros/graph/PGplot/Tikz.pl new file mode 100644 index 0000000000..7952b43b20 --- /dev/null +++ b/macros/graph/PGplot/Tikz.pl @@ -0,0 +1,289 @@ +################################################################################ +# 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. +################################################################################ + +BEGIN { + strict->import; +} + +sub _Tikz_init { } + +package PGplot::Tikz; + +sub new { + my ($class, $pgplot) = @_; + my $image = new LaTeXImage; + $image->environment('tikzpicture'); + $image->svgMethod($main::envir{latexImageSVGMethod} // 'pdf2svg'); + $image->convertOptions($main::envir{latexImageConvertOptions} // { input => {}, output => {} }); + $image->ext($pgplot->ext); + $image->tikzLibraries('arrows.meta'); + $image->texPackages(['pgfplots']); + $image->addToPreamble('\pgfplotsset{compat=1.18}\usepgfplotslibrary{fillbetween}'); + + my $self = { + image => $image, + pgplot => $pgplot, + colors => {}, + }; + bless $self, $class; + + return $self; +} + +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/graph/VectorField2D.pl b/macros/graph/VectorField2D.pl index 3cc8a2d7dd..e0223a62ab 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 'PGplot') { 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 'PGplot'); + # Generate plot data my $dx = ($options{xmax} - $options{xmin}) / $options{xsamples}; my $dy = ($options{ymax} - $options{ymin}) / $options{ysamples}; diff --git a/macros/graph/unionImage.pl b/macros/graph/unionImage.pl index 9dcc4e5b6a..f1b434409d 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 'PGplot'); + $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; } From a60e2813aad5169a9f00c1c78623becde86dc27e Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Sun, 24 Mar 2024 07:22:06 -0400 Subject: [PATCH 2/5] Move Plots components to lib/. This renames the PGplot object to Plots, and moves all the code from macros/ to lib/. This adds a new macro plots.pl that loads the core Plots::Plot object via the Plot method. --- conf/pg_config.dist.yml | 1 + .../graph/PGplot/Axes.pl => lib/Plots/Axes.pm | 24 +- .../graph/PGplot/Data.pl => lib/Plots/Data.pm | 52 +-- macros/graph/PGplot/GD.pl => lib/Plots/GD.pm | 12 +- lib/Plots/Plot.pm | 379 +++++++++++++++++ .../graph/PGplot/Tikz.pl => lib/Plots/Tikz.pm | 12 +- macros/graph/{PGplot.pl => plots.pl} | 387 +----------------- 7 files changed, 439 insertions(+), 428 deletions(-) rename macros/graph/PGplot/Axes.pl => lib/Plots/Axes.pm (95%) rename macros/graph/PGplot/Data.pl => lib/Plots/Data.pm (81%) rename macros/graph/PGplot/GD.pl => lib/Plots/GD.pm (98%) create mode 100644 lib/Plots/Plot.pm rename macros/graph/PGplot/Tikz.pl => lib/Plots/Tikz.pm (98%) rename macros/graph/{PGplot.pl => plots.pl} (55%) diff --git a/conf/pg_config.dist.yml b/conf/pg_config.dist.yml index 9fedd6e308..359fa94aba 100644 --- a/conf/pg_config.dist.yml +++ b/conf/pg_config.dist.yml @@ -230,6 +230,7 @@ modules: - [Multiple] - [PGrandom] - [Regression] + - ['Plots::Plot', 'Plots::Tikz', 'Plots::GD', 'Plots::Data', 'Plots::Axes'] - [Select] - [Units] - [VectorField] diff --git a/macros/graph/PGplot/Axes.pl b/lib/Plots/Axes.pm similarity index 95% rename from macros/graph/PGplot/Axes.pl rename to lib/Plots/Axes.pm index 07c7b5ae90..b9e8175623 100644 --- a/macros/graph/PGplot/Axes.pl +++ b/lib/Plots/Axes.pm @@ -13,11 +13,7 @@ # Artistic License for more details. ################################################################################ -=head1 NAME - -PGplot/Axes.pl - Object used with PGplot to store data about a plot's title and axes. - -=head1 DESCRIPTION +=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: @@ -40,7 +36,7 @@ =head1 DESCRIPTION =head1 USAGE -The axes object should be accessed through a L object using C<$plot-Eaxes>. +The axes object should be accessed through a PGplot object using C<< $plot->axes >>. The axes object is used to configure and retrieve information about the axes, as in the following examples. @@ -84,7 +80,7 @@ =head1 USAGE $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-Eaxes-Ebounds> method. +using the C<< $plot->axes->bounds >> method. =head1 AXIS CONFIGURATION OPTIONS @@ -116,7 +112,7 @@ =head1 AXIS CONFIGURATION OPTIONS 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. +and the number of ticks. Default: 5. =item label @@ -192,13 +188,7 @@ =head1 STYLES =cut -BEGIN { - strict->import; -} - -sub _Axes_init { } - -package PGplot::Axes; +package Plots::Axes; sub new { my $class = shift; @@ -224,9 +214,9 @@ sub new { sub axis_defaults { my ($self, $axis) = @_; return ( - visible => 1, + visible => 1, min => -5, - max => 5, + max => 5, label => $axis eq 'y' ? '\(y\)' : '\(x\)', location => $axis eq 'y' ? 'center' : 'middle', position => 0, diff --git a/macros/graph/PGplot/Data.pl b/lib/Plots/Data.pm similarity index 81% rename from macros/graph/PGplot/Data.pl rename to lib/Plots/Data.pm index f261748e7f..4ef30bc17c 100644 --- a/macros/graph/PGplot/Data.pl +++ b/lib/Plots/Data.pm @@ -13,17 +13,13 @@ # Artistic License for more details. ################################################################################ -=head1 NAME +=head1 DATA OBJECT -Data.pl - Base data class for PGplot elements (functions, labels, etc). - -=head1 DESCRIPTION - -This is a data class to hold data about the different types of elements -that can be added to a PGplot graph. This is a hash with some helper methods. -Data objects are created and modified using the L 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. +This object holds data about the different types of elements that can be added +to a PGplot graph. This is a hash with some helper methods. Data objects are created +and modified using the PGplot 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: @@ -59,16 +55,16 @@ =head1 USAGE =over 5 -=item C<$data-Ename> +=item C<< $data->name >> -Sets, C<$data-Ename($string)>, or gets C<$data-Ename> the name of the data object. +Sets, C<< $data->name($string) >>, or gets C<< $data->name >> the name of the data object. -=item C<$data-Eadd> +=item C<< $data->add >> -Adds a single data point, C<$data-Eadd($x, $y)>, or adds multiple data points, -C<$data-Eadd([$x1, $y1], [$x2, $y2], ..., [$xn, $yn])>. +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-Eset_function> +=item C<< $data->set_function >> Configures a function to generate data points. C and C are are perl subroutines. @@ -83,24 +79,24 @@ =head1 USAGE $data->style(steps => 50); -=item C<$data-Egen_data> +=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-Esize> +=item C<< $data->size >> Returns the current number of points being stored. -=item C<$data-Ex> and C<$data-Ey> +=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-Ex($n)>. +A single input can be used to return only the n-th data point, C<< $data->x($n) >>. -=item C<$data-Estyle> +=item C<< $data->style >> -Sets or gets style information. Use C<$data-Estyle($name)> to get the style value of a single -style name. C<$data-Estyle> will returns a reference to the full style hash. Last, input a hash +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); @@ -109,13 +105,7 @@ =head1 USAGE =cut -BEGIN { - strict->import; -} - -sub _Data_init { } - -package PGplot::Data; +package Plots::Data; sub new { my $class = shift; @@ -178,7 +168,7 @@ sub set_function { sub_x => sub { return $_[0]; }, sub_y => sub { return $_[0]; }, min => -5, - max => 5, + max => 5, @_ }; $self->style(steps => $self->{function}{steps}) if $self->{funciton}{steps}; diff --git a/macros/graph/PGplot/GD.pl b/lib/Plots/GD.pm similarity index 98% rename from macros/graph/PGplot/GD.pl rename to lib/Plots/GD.pm index c6b6a2bc2d..b3b174fbc4 100644 --- a/macros/graph/PGplot/GD.pl +++ b/lib/Plots/GD.pm @@ -13,13 +13,15 @@ # Artistic License for more details. ################################################################################ -BEGIN { - strict->import; -} +=head1 DESCRIPTION + +This is the code that takes a C and creates the GD code for generation. + +See L for more details. -sub _GD_init { } +=cut -package PGplot::GD; +package Plots::GD; sub new { my ($class, $pgplot) = @_; diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm new file mode 100644 index 0000000000..c60b74ab7e --- /dev/null +++ b/lib/Plots/Plot.pm @@ -0,0 +1,379 @@ +################################################################################ +# 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 Plots::Axes; +use Plots::Data; +use Plots::GD; +use Plots::Tikz; + +sub new { + my ($class, $pg, @opts) = @_; + my $size = $main::envir{onTheFlyImageSize} || 500; + + my $self = { + pg => $pg, + imageName => {}, + type => 'Tikz', + ext => 'svg', + size => [ $size, $size ], + axes => Plots::Axes->new, + colors => {}, + data => [], + @opts + }; + + bless $self, $class; + $self->color_init; + return $self; +} + +sub colors { + my ($self, $color) = @_; + return defined($color) ? $self->{colors}{$color} : $self->{colors}; +} + +sub _add_color { + my ($self, $color, $r, $g, $b) = @_; + $self->{'colors'}{$color} = [ $r, $g, $b ]; + return; +} + +sub add_color { + my $self = shift; + if (ref($_[0]) eq 'ARRAY') { + for (@_) { $self->_add_color(@$_); } + } else { + $self->_add_color(@_); + } + 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->{size} } : $self->{size}; +} + +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 'tikz') { + $self->{type} = 'Tikz'; + @validExt = ('svg', 'png', 'pdf'); + } elsif ($type eq 'gd') { + $self->{type} = 'GD'; + @validExt = ('png', 'gif'); + } else { + warn "PGplot: Invalid image type $type."; + return; + } + + if ($ext) { + if (grep(/^$ext$/, @validExt)) { + $self->{ext} = $ext; + } else { + warn "PGplot: Invalid image extension $ext."; + } + } else { + $self->{ext} = $validExt[0]; + } + return; +} + +# Tikz needs to use pdf for hardcopy generation. +sub ext { + my $self = shift; + return 'pdf' if ($self->{type} eq 'Tikz' && $main::displayMode eq 'TeX'); + return $self->{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} && $main::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 = shift; + if (ref($_[0]) eq 'ARRAY' && ref($_[0]->[0]) eq 'ARRAY') { + return [ map { $self->_add_dataset(@$_); } @_ ]; + } + return $self->_add_dataset(@_); +} + +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 = shift; + return ref($_[0]) eq 'ARRAY' ? [ map { $self->_add_label(@$_); } @_ ] : $self->_add_label(@_); +} + +# 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 = shift; + return ref($_[0]) eq 'ARRAY' ? [ map { $self->_add_fill_region(@$_); } @_ ] : $self->_add_fill_region(@_); +} + +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 = shift; + return ref($_[0]) eq 'ARRAY' ? [ map { $self->_add_stamp(@$_); } @_ ] : $self->_add_stamp(@_); +} + +# Output the image based on a configurable type: +sub draw { + my $self = shift; + my $type = $self->{type}; + + my $image; + if ($type eq 'GD') { + $image = Plots::GD->new($self); + } elsif ($type eq 'Tikz') { + $image = Plots::Tikz->new($self); + } else { + warn "Undefined image type: $type"; + return; + } + return $image->draw; +} + +1; diff --git a/macros/graph/PGplot/Tikz.pl b/lib/Plots/Tikz.pm similarity index 98% rename from macros/graph/PGplot/Tikz.pl rename to lib/Plots/Tikz.pm index 7952b43b20..142346a5b3 100644 --- a/macros/graph/PGplot/Tikz.pl +++ b/lib/Plots/Tikz.pm @@ -13,13 +13,15 @@ # Artistic License for more details. ################################################################################ -BEGIN { - strict->import; -} +=head1 DESCRIPTION + +This is the code that takes a C and creates the tikz code for generation. + +See L for more details. -sub _Tikz_init { } +=cut -package PGplot::Tikz; +package Plots::Tikz; sub new { my ($class, $pgplot) = @_; diff --git a/macros/graph/PGplot.pl b/macros/graph/plots.pl similarity index 55% rename from macros/graph/PGplot.pl rename to macros/graph/plots.pl index 2602f86f54..38acb49ab5 100644 --- a/macros/graph/PGplot.pl +++ b/macros/graph/plots.pl @@ -15,11 +15,11 @@ =head1 NAME -PGplot.pl - An object to create dynamic graphs to include in PG problems. +plots.pl - A macro to create dynamic graphs to include in PG problems. =head1 DESCRIPTION -This macro creates a PGplot 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 the legacy GD graphics format and TikZ (using pgfplots) are available. @@ -28,10 +28,10 @@ =head1 USAGE First create a PGplot object: - loadMacros('PGplot.pl'); - $plot = PGplot(); + loadMacros('plots.pl'); + $plot = Plot(); -Configure the L: +Configure the L: $plot->axes->xaxis( min => 0, @@ -54,19 +54,19 @@ =head1 USAGE Insert the graph into the problem. BEGIN_PGML - [@ image(insertGraph($plot), width => 500) @]* + [! Plot of a quadratic function !]{$plot}{500} END_PGML =head1 PLOT ELEMENTS -A plot consists of multiple L objects, which define datasets, functions, +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 PGplot 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-Eadd_dataset>, and +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 @@ -90,7 +90,7 @@ =head2 DATASETS [[0, 0], [4, -1], color => 'red', end_mark => 'arrow'], ); -If needed, the C<$plot-Eadd_dataset> method returns the L object +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(...); @@ -144,7 +144,7 @@ =head2 PLOT FUNCTIONS '(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 +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. @@ -272,7 +272,7 @@ =head2 DATASET OPTIONS =head2 LABELS -Labels can be added to the graph using the C<$plot-Eadd_label> method. +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). @@ -374,7 +374,7 @@ =head2 COLORS gray 180 180 180 nearwhite 254 254 254 -New colors can be added, or existing colors can be modified, using the C<$plot-Eadd_color> method. +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. @@ -388,11 +388,11 @@ =head2 COLORS =head1 TIKZ DEBUGGING -When using Tikz output, the pgfplots code used to create the plot is stored in C<$plot-E{tikzCode}>, +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-E{tikzDebug}>, which if set will bypass building the graph with latex, allowing +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-Etikz_code> will return the code in pre tags to format inside a problem. For instance to view +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; @@ -408,359 +408,6 @@ BEGIN strict->import; } -loadMacros('MathObjects.pl', 'PGplot/Axes.pl', 'PGplot/Data.pl', 'PGplot/GD.pl', 'PGplot/Tikz.pl'); +sub _plots_init { } -sub _PGplot_init { } - -sub PGplot { PGplot->new(@_); } - -package PGplot; - -sub new { - my $class = shift; - my $size = $main::envir{onTheFlyImageSize} || 500; - - my $self = { - imageName => {}, - type => 'Tikz', - ext => 'svg', - size => [ $size, $size ], - axes => PGplot::Axes->new, - colors => {}, - data => [], - @_ - }; - - bless $self, $class; - $self->color_init; - return $self; -} - -sub colors { - my ($self, $color) = @_; - return defined($color) ? $self->{colors}{$color} : $self->{colors}; -} - -sub _add_color { - my ($self, $color, $r, $g, $b) = @_; - $self->{'colors'}{$color} = [ $r, $g, $b ]; - return; -} - -sub add_color { - my $self = shift; - if (ref($_[0]) eq 'ARRAY') { - for (@_) { $self->_add_color(@$_); } - } else { - $self->_add_color(@_); - } - 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->{size} } : $self->{size}; -} - -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} = $main::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 'tikz') { - $self->{type} = 'Tikz'; - @validExt = ('svg', 'png', 'pdf'); - } elsif ($type eq 'gd') { - $self->{type} = 'GD'; - @validExt = ('png', 'gif'); - } else { - warn "PGplot: Invalid image type $type."; - return; - } - - if ($ext) { - if (grep(/^$ext$/, @validExt)) { - $self->{ext} = $ext; - } else { - warn "PGplot: Invalid image extension $ext."; - } - } else { - $self->{ext} = $validExt[0]; - } - return; -} - -# Tikz needs to use pdf for hardcopy generation. -sub ext { - my $self = shift; - return 'pdf' if ($self->{type} eq 'Tikz' && $main::displayMode eq 'TeX'); - return $self->{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} && $main::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 = PGplot::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 = PGplot::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 = shift; - if (ref($_[0]) eq 'ARRAY' && ref($_[0]->[0]) eq 'ARRAY') { - return [ map { $self->_add_dataset(@$_); } @_ ]; - } - return $self->_add_dataset(@_); -} - -sub _add_label { - my ($self, $x, $y, @options) = @_; - my $data = PGplot::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 = shift; - return ref($_[0]) eq 'ARRAY' ? [ map { $self->_add_label(@$_); } @_ ] : $self->_add_label(@_); -} - -# Fill regions only work with GD and are ignored in TikZ images. -sub _add_fill_region { - my ($self, $x, $y, $color) = @_; - my $data = PGplot::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 = shift; - return ref($_[0]) eq 'ARRAY' ? [ map { $self->_add_fill_region(@$_); } @_ ] : $self->_add_fill_region(@_); -} - -sub _add_stamp { - my ($self, $x, $y, @options) = @_; - my $data = PGplot::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 = shift; - return ref($_[0]) eq 'ARRAY' ? [ map { $self->_add_stamp(@$_); } @_ ] : $self->_add_stamp(@_); -} - -# Output the image based on a configurable type: -sub draw { - my $self = shift; - my $type = $self->{type}; - - my $image; - if ($type eq 'GD') { - $image = PGplot::GD->new($self); - } elsif ($type eq 'Tikz') { - $image = PGplot::Tikz->new($self); - } else { - warn "Undefined image type: $type"; - return; - } - return $image->draw; -} - -1; +sub Plot { Plots::Plot->new($main::PG, @_); } From 995e7e505efd038ae1bdebcbfc2465f0ee994cdc Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Wed, 14 Aug 2024 15:56:23 -0600 Subject: [PATCH 3/5] Add use strict and use warnings to Plots modules. --- lib/Plots/Axes.pm | 3 +++ lib/Plots/Data.pm | 3 +++ lib/Plots/GD.pm | 5 +++++ lib/Plots/Plot.pm | 5 ++++- lib/Plots/Tikz.pm | 3 +++ 5 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/Plots/Axes.pm b/lib/Plots/Axes.pm index b9e8175623..6c20059543 100644 --- a/lib/Plots/Axes.pm +++ b/lib/Plots/Axes.pm @@ -190,6 +190,9 @@ be visible after the fill, otherwise the fill will cover the axis. Default: 0 package Plots::Axes; +use strict; +use warnings; + sub new { my $class = shift; my $self = { diff --git a/lib/Plots/Data.pm b/lib/Plots/Data.pm index 4ef30bc17c..cd153d14a4 100644 --- a/lib/Plots/Data.pm +++ b/lib/Plots/Data.pm @@ -107,6 +107,9 @@ to add / change the styles. package Plots::Data; +use strict; +use warnings; + sub new { my $class = shift; my $self = { diff --git a/lib/Plots/GD.pm b/lib/Plots/GD.pm index b3b174fbc4..a3da9498aa 100644 --- a/lib/Plots/GD.pm +++ b/lib/Plots/GD.pm @@ -23,6 +23,11 @@ See L for more details. package Plots::GD; +use GD; + +use strict; +use warnings; + sub new { my ($class, $pgplot) = @_; my $self = { diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm index c60b74ab7e..82b344acab 100644 --- a/lib/Plots/Plot.pm +++ b/lib/Plots/Plot.pm @@ -23,10 +23,13 @@ See L for more details. package Plots::Plot; +use strict; +use warnings; + use Plots::Axes; use Plots::Data; -use Plots::GD; use Plots::Tikz; +use Plots::GD; sub new { my ($class, $pg, @opts) = @_; diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 142346a5b3..8e08125226 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -23,6 +23,9 @@ See L for more details. package Plots::Tikz; +use strict; +use warnings; + sub new { my ($class, $pgplot) = @_; my $image = new LaTeXImage; From ce1e1e308dd70bed75832001c64332dfc2ae7ca8 Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Wed, 14 Aug 2024 20:16:40 -0600 Subject: [PATCH 4/5] Code review changes from drgrice1. Mostly unpacking function calls, blessing objects in a single line, fixing perl calls, along with other code cleanup suggestions. --- lib/Plots/Axes.pm | 13 ++++++----- lib/Plots/Data.pm | 26 +++++++--------------- lib/Plots/GD.pm | 13 +++++------ lib/Plots/Plot.pm | 55 +++++++++++++++++++++++------------------------ lib/Plots/Tikz.pm | 15 ++++--------- 5 files changed, 50 insertions(+), 72 deletions(-) diff --git a/lib/Plots/Axes.pm b/lib/Plots/Axes.pm index 6c20059543..506f893b8d 100644 --- a/lib/Plots/Axes.pm +++ b/lib/Plots/Axes.pm @@ -195,7 +195,7 @@ use warnings; sub new { my $class = shift; - my $self = { + my $self = bless { xaxis => {}, yaxis => {}, styles => { @@ -206,9 +206,8 @@ sub new { show_grid => 1, }, @_ - }; + }, $class; - bless $self, $class; $self->xaxis($self->axis_defaults('x')); $self->yaxis($self->axis_defaults('y')); return $self; @@ -249,13 +248,13 @@ sub axis { } sub xaxis { - my $self = shift; - return $self->axis('xaxis', @_); + my ($self, @items) = @_; + return $self->axis('xaxis', @items); } sub yaxis { - my $self = shift; - return $self->axis('yaxis', @_); + my ($self, @items) = @_; + return $self->axis('yaxis', @items); } sub set { diff --git a/lib/Plots/Data.pm b/lib/Plots/Data.pm index cd153d14a4..54a9b1f347 100644 --- a/lib/Plots/Data.pm +++ b/lib/Plots/Data.pm @@ -111,18 +111,8 @@ use strict; use warnings; sub new { - my $class = shift; - my $self = { - name => '', - x => [], - y => [], - function => {}, - styles => {}, - @_ - }; - - bless $self, $class; - return $self; + my ($class, %options) = @_; + return bless { name => '', x => [], y => [], function => {}, styles => {}, %options }, $class; } sub name { @@ -166,13 +156,13 @@ sub style { } sub set_function { - my $self = shift; + 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; @@ -211,11 +201,11 @@ sub _add { } sub add { - my $self = shift; - if (ref($_[0]) eq 'ARRAY') { - for (@_) { $self->_add(@$_); } + my ($self, @points) = @_; + if (ref($points[0]) eq 'ARRAY') { + for (@points) { $self->_add(@$_); } } else { - $self->_add(@_); + $self->_add(@points); } return; } diff --git a/lib/Plots/GD.pm b/lib/Plots/GD.pm index a3da9498aa..0cbfe4b8d4 100644 --- a/lib/Plots/GD.pm +++ b/lib/Plots/GD.pm @@ -30,16 +30,13 @@ use warnings; sub new { my ($class, $pgplot) = @_; - my $self = { + return bless { image => '', pgplot => $pgplot, position => [ 0, 0 ], colors => {}, - }; - bless $self, $class; - - $self->{image} = new GD::Image($pgplot->size); - return $self; + image => GD::Image->new($pgplot->size) + }, $class; } sub pgplot { @@ -199,7 +196,7 @@ sub draw_label { sub draw_arrow_head { my ($self, $x1, $y1, $x2, $y2, $color, $w) = @_; - return unless scalar(@_) > 4; + return unless @_ > 4; $color = $self->color($color || 'default_color'); $w = 1 unless $w; ($x1, $y1) = ($self->im_x($x1), $self->im_y($y1)); @@ -214,7 +211,7 @@ sub draw_arrow_head { my $py = $ux; my $hbx = $x2 - 7 * $w * $ux; my $hby = $y2 - 7 * $w * $uy; - my $head = new GD::Polygon; + 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); diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm index 82b344acab..6a314be2f5 100644 --- a/lib/Plots/Plot.pm +++ b/lib/Plots/Plot.pm @@ -32,10 +32,10 @@ use Plots::Tikz; use Plots::GD; sub new { - my ($class, $pg, @opts) = @_; + my ($class, $pg, %options) = @_; my $size = $main::envir{onTheFlyImageSize} || 500; - my $self = { + my $self = bless { pg => $pg, imageName => {}, type => 'Tikz', @@ -44,10 +44,9 @@ sub new { axes => Plots::Axes->new, colors => {}, data => [], - @opts - }; + %options + }, $class; - bless $self, $class; $self->color_init; return $self; } @@ -57,18 +56,12 @@ sub colors { return defined($color) ? $self->{colors}{$color} : $self->{colors}; } -sub _add_color { - my ($self, $color, $r, $g, $b) = @_; - $self->{'colors'}{$color} = [ $r, $g, $b ]; - return; -} - sub add_color { - my $self = shift; - if (ref($_[0]) eq 'ARRAY') { - for (@_) { $self->_add_color(@$_); } + my ($self, @colors) = @_; + if (ref($colors[0]) eq 'ARRAY') { + for (@colors) { $self->{colors}{ $_->[0] } = [ @$_[ 1 .. 3 ] ]; } } else { - $self->_add_color(@_); + $self->{colors}{ $colors[0] } = [ @colors[ 1 .. 3 ] ]; } return; } @@ -98,7 +91,10 @@ sub size { sub data { my ($self, @names) = @_; return wantarray ? @{ $self->{data} } : $self->{data} unless @names; - my @data = grep { my $name = $_->name; grep(/^$name$/, @names) } @{ $self->{data} }; + my @data = grep { + my $name = $_->name; + grep {/^$name$/} @names + } @{ $self->{data} }; return wantarray ? @data : \@data; } @@ -161,7 +157,7 @@ sub image_type { # Tikz needs to use pdf for hardcopy generation. sub ext { my $self = shift; - return 'pdf' if ($self->{type} eq 'Tikz' && $main::displayMode eq 'TeX'); + return 'pdf' if ($self->{type} eq 'Tikz' && eval('$main::displayMode') eq 'TeX'); return $self->{ext}; } @@ -169,7 +165,7 @@ sub ext { # Set $plot->{tikzDebug} to 1 to just generate the tikzCode, and not create a graph. sub tikz_code { my $self = shift; - return ($self->{tikzCode} && $main::displayMode =~ /HTML/) ? '
' . $self->{tikzCode} . '
' : ''; + return ($self->{tikzCode} && eval('$main::displayMode') =~ /HTML/) ? '
' . $self->{tikzCode} . '
' : ''; } # Add functions to the graph. @@ -298,11 +294,11 @@ sub _add_dataset { } sub add_dataset { - my $self = shift; - if (ref($_[0]) eq 'ARRAY' && ref($_[0]->[0]) eq 'ARRAY') { - return [ map { $self->_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(@_); + return $self->_add_dataset(@data); } sub _add_label { @@ -324,8 +320,8 @@ sub _add_label { } sub add_label { - my $self = shift; - return ref($_[0]) eq 'ARRAY' ? [ map { $self->_add_label(@$_); } @_ ] : $self->_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. @@ -339,8 +335,11 @@ sub _add_fill_region { } sub add_fill_region { - my $self = shift; - return ref($_[0]) eq 'ARRAY' ? [ map { $self->_add_fill_region(@$_); } @_ ] : $self->_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 { @@ -358,8 +357,8 @@ sub _add_stamp { } sub add_stamp { - my $self = shift; - return ref($_[0]) eq 'ARRAY' ? [ map { $self->_add_stamp(@$_); } @_ ] : $self->_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: diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 8e08125226..b9a884f490 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -28,23 +28,16 @@ use warnings; sub new { my ($class, $pgplot) = @_; - my $image = new LaTeXImage; + my $image = LaTeXImage->new; $image->environment('tikzpicture'); - $image->svgMethod($main::envir{latexImageSVGMethod} // 'pdf2svg'); + $image->svgMethod($main::envir{latexImageSVGMethod} // 'dvisvgm'); $image->convertOptions($main::envir{latexImageConvertOptions} // { input => {}, output => {} }); $image->ext($pgplot->ext); - $image->tikzLibraries('arrows.meta'); + $image->tikzLibraries('arrows.meta,plotmarks'); $image->texPackages(['pgfplots']); $image->addToPreamble('\pgfplotsset{compat=1.18}\usepgfplotslibrary{fillbetween}'); - my $self = { - image => $image, - pgplot => $pgplot, - colors => {}, - }; - bless $self, $class; - - return $self; + return bless { image => $image, pgplot => $pgplot, colors => {} }, $class; } sub pgplot { From 0a341861ec50d6983dedd4e3d58b36cb5e4d4d3c Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Sun, 10 Nov 2024 04:05:46 -0700 Subject: [PATCH 5/5] WIP: Add JSXGraph and Plotly.js graph output to Plots. --- conf/pg_config.dist.yml | 2 +- lib/Plots/Axes.pm | 2 +- lib/Plots/Data.pm | 9 +- lib/Plots/GD.pm | 9 +- lib/Plots/JSX.pm | 174 ++++++++++++++++++++++++++++++++++ lib/Plots/Plot.pm | 63 +++++++++--- lib/Plots/Plotly.pm | 104 ++++++++++++++++++++ macros/core/PGbasicmacros.pl | 12 ++- macros/graph/VectorField2D.pl | 4 +- macros/graph/plots.pl | 13 ++- macros/graph/unionImage.pl | 6 +- 11 files changed, 362 insertions(+), 36 deletions(-) create mode 100644 lib/Plots/JSX.pm create mode 100644 lib/Plots/Plotly.pm diff --git a/conf/pg_config.dist.yml b/conf/pg_config.dist.yml index 359fa94aba..8484d612ea 100644 --- a/conf/pg_config.dist.yml +++ b/conf/pg_config.dist.yml @@ -230,7 +230,7 @@ modules: - [Multiple] - [PGrandom] - [Regression] - - ['Plots::Plot', 'Plots::Tikz', 'Plots::GD', 'Plots::Data', 'Plots::Axes'] + - ['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 index 506f893b8d..e7036c2c3e 100644 --- a/lib/Plots/Axes.pm +++ b/lib/Plots/Axes.pm @@ -36,7 +36,7 @@ Hash of data for options for the general axis. =head1 USAGE -The axes object should be accessed through a PGplot object using C<< $plot->axes >>. +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. diff --git a/lib/Plots/Data.pm b/lib/Plots/Data.pm index 54a9b1f347..abfad10681 100644 --- a/lib/Plots/Data.pm +++ b/lib/Plots/Data.pm @@ -15,11 +15,10 @@ =head1 DATA OBJECT -This object holds data about the different types of elements that can be added -to a PGplot graph. This is a hash with some helper methods. Data objects are created -and modified using the PGplot 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. +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: diff --git a/lib/Plots/GD.pm b/lib/Plots/GD.pm index 0cbfe4b8d4..f83d7ff9da 100644 --- a/lib/Plots/GD.pm +++ b/lib/Plots/GD.pm @@ -69,7 +69,7 @@ sub im_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->size)[0] / ($xmax - $xmin)); + return int(($x - $xmin) * $pgplot->{width} / ($xmax - $xmin)); } sub im_y { @@ -77,7 +77,7 @@ sub im_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->size)[1] / ($ymax - $ymin)); + return int(($ymax - $y) * $pgplot->{height} / ($ymax - $ymin)); } sub moveTo { @@ -235,7 +235,8 @@ sub draw { my $pgplot = $self->pgplot; my $axes = $pgplot->axes; my $grid = $axes->grid; - my $size = $pgplot->size; + my $width = $pgplot->{width}; + my $height = $pgplot->{height}; # Initialize image $self->im->interlaced('true'); @@ -379,7 +380,7 @@ sub draw { } # Put a black frame around the picture - $self->im->rectangle(0, 0, $size->[0] - 1, $size->[1] - 1, $self->color('black')); + $self->im->rectangle(0, 0, $width - 1, $height - 1, $self->color('black')); return $pgplot->ext eq 'gif' ? $self->im->gif : $self->im->png; } diff --git a/lib/Plots/JSX.pm b/lib/Plots/JSX.pm new file mode 100644 index 0000000000..7b052c464d --- /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 index 6a314be2f5..7672ffe261 100644 --- a/lib/Plots/Plot.pm +++ b/lib/Plots/Plot.pm @@ -29,6 +29,8 @@ use warnings; use Plots::Axes; use Plots::Data; use Plots::Tikz; +use Plots::Plotly; +use Plots::JSX; use Plots::GD; sub new { @@ -39,8 +41,10 @@ sub new { pg => $pg, imageName => {}, type => 'Tikz', - ext => 'svg', - size => [ $size, $size ], + ext => $pg->{displayMode} eq 'TeX' ? 'pdf' : 'svg', + width => $size, + height => $size, + tex_size => 500, axes => Plots::Axes->new, colors => {}, data => [], @@ -51,6 +55,24 @@ sub new { 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}; @@ -85,7 +107,7 @@ sub color_init { sub size { my $self = shift; - return wantarray ? @{ $self->{size} } : $self->{size}; + return wantarray ? ($self->{width}, $self->{height}) : [ $self->{width}, $self->{height} ]; } sub data { @@ -131,14 +153,20 @@ sub image_type { # Check type and extension are valid. The first element of @validExt is used as default. my @validExt; $type = lc($type); - if ($type eq 'tikz') { + 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 "PGplot: Invalid image type $type."; + warn "Plots: Invalid image type $type."; return; } @@ -146,26 +174,29 @@ sub image_type { if (grep(/^$ext$/, @validExt)) { $self->{ext} = $ext; } else { - warn "PGplot: Invalid image extension $ext."; + 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; } -# Tikz needs to use pdf for hardcopy generation. sub ext { - my $self = shift; - return 'pdf' if ($self->{type} eq 'Tikz' && eval('$main::displayMode') eq 'TeX'); - return $self->{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} && eval('$main::displayMode') =~ /HTML/) ? '
' . $self->{tikzCode} . '
' : ''; + return $self->{tikzCode} && $self->{pg}{displayMode} =~ /HTML/ ? '
' . $self->{tikzCode} . '
' : ''; } # Add functions to the graph. @@ -367,10 +398,14 @@ sub draw { my $type = $self->{type}; my $image; - if ($type eq 'GD') { - $image = Plots::GD->new($self); - } elsif ($type eq 'Tikz') { + 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; diff --git a/lib/Plots/Plotly.pm b/lib/Plots/Plotly.pm new file mode 100644 index 0000000000..629d6f5744 --- /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/macros/core/PGbasicmacros.pl b/macros/core/PGbasicmacros.pl index 89fde80139..f7ba8b97a4 100644 --- a/macros/core/PGbasicmacros.pl +++ b/macros/core/PGbasicmacros.pl @@ -2909,9 +2909,19 @@ 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 'PGplot' + || ref $image_item eq 'Plots::Plot' || ref $image_item eq 'PGlateximage' || ref $image_item eq 'PGtikz'); my $imageURL = alias($image_item) // ''; diff --git a/macros/graph/VectorField2D.pl b/macros/graph/VectorField2D.pl index e0223a62ab..369e749ae1 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' || ref($gr) eq 'PGplot') { + unless (ref($gr) eq 'WWPlot' || ref($gr) eq 'Plots::Plot') { warn 'VectorField2D: Invalid graphobject provided.'; return; } @@ -130,7 +130,7 @@ sub VectorField2D { } # Takes to long to render this field using Tikz, force GD output. - $gr->image_type('GD') if (ref($gr) eq 'PGplot'); + $gr->image_type('GD') if (ref($gr) eq 'Plots::Plot'); # Generate plot data my $dx = ($options{xmax} - $options{xmin}) / $options{xsamples}; diff --git a/macros/graph/plots.pl b/macros/graph/plots.pl index 38acb49ab5..1ea9717a3c 100644 --- a/macros/graph/plots.pl +++ b/macros/graph/plots.pl @@ -19,14 +19,17 @@ =head1 NAME =head1 DESCRIPTION -This macro creates a Plot object that is used to add data of different +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 the legacy GD graphics format and TikZ (using pgfplots) -are available. +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 PGplot object: +First create a Plots object: loadMacros('plots.pl'); $plot = Plot(); @@ -60,7 +63,7 @@ =head1 USAGE =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 PGplot object, +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 diff --git a/macros/graph/unionImage.pl b/macros/graph/unionImage.pl index f1b434409d..f4438dc59b 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" || ref($image) eq 'PGplot'); - $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; }