From dbfc47ec23567eed664d345920d4106d30832da6 Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Sun, 9 Apr 2023 23:50:02 -0600 Subject: [PATCH 01/19] 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 3d24df091f..c03afa0ca7 100644 --- a/macros/core/PGbasicmacros.pl +++ b/macros/core/PGbasicmacros.pl @@ -2839,7 +2839,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 4449b9eee0..c859b0c831 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 4df4b00e9d9973f0dfe209b549f0b1a264480723 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Sun, 24 Mar 2024 07:22:06 -0400 Subject: [PATCH 02/19] 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 4a6e201a33..497c646367 100644 --- a/conf/pg_config.dist.yml +++ b/conf/pg_config.dist.yml @@ -236,6 +236,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 dcb12d5b523e8aa7e0ac54d1e27ae65c69379a37 Mon Sep 17 00:00:00 2001 From: Peter Staab Date: Wed, 14 Aug 2024 15:56:23 -0600 Subject: [PATCH 03/19] 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 153315c08a22a832d02e519fb6d870fd8adcd7c8 Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Wed, 14 Aug 2024 20:16:40 -0600 Subject: [PATCH 04/19] 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 a61ff53550f56417336329744543967d727f97c2 Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Sun, 10 Nov 2024 04:05:46 -0700 Subject: [PATCH 05/19] Add JSXGraph graph output to Plots. * Add 'JSXGraph' output for JSXgraph for html, which falls back to 'Tikz' output for hardcopy. This is now the default output. * Unless perl subroutines are used, JSXGraph will pass the functions to the client for generation. Generates faster and smoother graphs. * Standardize various output options to be consistent between JSXGraph and Tikz, such as which marks are available. * Update the Plots::Data function object to store MathObjects instead of perl subroutines. Only swap to perl subroutines when generating the data set from the function. This gives an option to just use the string of the function to pass data generation to the client via JSXGraph. * Since 'JSXGraph' doesn't support custom ticks list, make tick_delta the standard shown in examples. * POD cleanup and various other code cleanups discovered while testing changes. * Remove copyright from start of files. * Remove old pgplot name in favor of plots. * Added 'tikz_smooth' option to function graphs with tikz. By default this is on for functions and off for other datasets. * Added continue_left and continue_right options for graphing functions with JSXGraph for the function to continue either left of the minimum or right of the maximum. Allows for zooming and panning the graph. * Add JSXGraphOpts options to pass a hash reference of options to the various JSXGraph objects. * Added code so JSXGraph can fill between curves. * Use fill_min/fill_max vs fill_range to define a range to fill. * Add aria description and title to JSXGraph. Ensure plot options sent to the image function update the plots object. --- conf/pg_config.dist.yml | 2 +- lib/Plots/Axes.pm | 57 ++--- lib/Plots/Data.pm | 223 +++++++++++++++---- lib/Plots/GD.pm | 65 +++--- lib/Plots/JSXGraph.pm | 388 ++++++++++++++++++++++++++++++++++ lib/Plots/Plot.pm | 140 ++++++------ lib/Plots/Tikz.pm | 124 +++++------ macros/core/PGbasicmacros.pl | 21 +- macros/graph/VectorField2D.pl | 4 +- macros/graph/plots.pl | 151 +++++++------ macros/graph/unionImage.pl | 6 +- 11 files changed, 859 insertions(+), 322 deletions(-) create mode 100644 lib/Plots/JSXGraph.pm diff --git a/conf/pg_config.dist.yml b/conf/pg_config.dist.yml index 497c646367..3e4c9ab080 100644 --- a/conf/pg_config.dist.yml +++ b/conf/pg_config.dist.yml @@ -236,7 +236,7 @@ modules: - [Multiple] - [PGrandom] - [Regression] - - ['Plots::Plot', 'Plots::Tikz', 'Plots::GD', 'Plots::Data', 'Plots::Axes'] + - ['Plots::Plot', 'Plots::Axes', 'Plots::Data', 'Plots::Tikz', 'Plots::JSXGraph', 'Plots::GD'] - [Select] - [Units] - [VectorField] diff --git a/lib/Plots/Axes.pm b/lib/Plots/Axes.pm index 506f893b8d..9a3e25602f 100644 --- a/lib/Plots/Axes.pm +++ b/lib/Plots/Axes.pm @@ -1,22 +1,9 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2023 The WeBWorK Project, https://github.com/openwebwork -# -# This program is free software; you can redistribute it and/or modify it under -# the terms of either: (a) the GNU General Public License as published by the -# Free Software Foundation; either version 2, or (at your option) any later -# version, or (b) the "Artistic License" which comes with this package. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the -# Artistic License for more details. -################################################################################ =head1 AXES OBJECT This is a hash to store information about the axes (ticks, range, grid, etc) with some helper methods. The hash is further split into three smaller hashes: +xaxis, yaxis, and styles. =over 5 @@ -36,24 +23,24 @@ 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. 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]); + $plot->axes->xaxis(min => -10, max => 10, tick_delta => 4); + $plot->axes->yaxis(min => 0, max => 100, tick_delta => 20); 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] + xmin => -10, + xmax => 10, + xtick_delta => 4, + ymin => 0, + ymax => 100, + ytick_delta => 20 ); In addition to the configuration each axis, there is a set of styles that apply to both axes. @@ -99,7 +86,9 @@ 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<[]>. +generated using either C or C. Note, JSXGraph doesn't support +this option, be sure to set C or C if using JSXGraph. +Default is C<[]>. =item tick_delta @@ -141,18 +130,27 @@ for each axis are: 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'. +Setting the location to 'box' creates a box or framed pot. Note, 'box' is not supported +by JSXgraph, so when using JSXgraph, 'box' is equivalent to 'bottom' or 'left'. +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. +=item JSXGraphOpts + +A hash reference of options to be passed to the JSXGraph axis objects. + =back =head1 STYLES The following styles configure aspects about the axes: +Currently only TikZ supports the grid color, style, alpha, +and axes on top styles. JSXGraph only supports the title +and show_grid styles. =over 5 @@ -184,6 +182,10 @@ Configures if the axis should be drawn on top of the graph (1) or below the grap 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 +=item JSXGraphOpts + +A hash reference of options to be passed to the JSXGraph board object. + =back =cut @@ -306,7 +308,10 @@ sub gen_ticks { my $min = $axis->{min}; my $max = $axis->{max}; my $delta = $axis->{tick_delta}; - $delta = ($max - $min) / $axis->{tick_num} unless $delta; + unless ($delta) { + $delta = ($max - $min) / $axis->{tick_num}; + $axis->{tick_delta} = $delta; + } my @ticks = $min <= 0 && $max >= 0 ? (0) : (); my $point = $delta; diff --git a/lib/Plots/Data.pm b/lib/Plots/Data.pm index 54a9b1f347..e8fe397c8b 100644 --- a/lib/Plots/Data.pm +++ b/lib/Plots/Data.pm @@ -1,25 +1,10 @@ -################################################################################ -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2023 The WeBWorK Project, https://github.com/openwebwork -# -# This program is free software; you can redistribute it and/or modify it under -# the terms of either: (a) the GNU General Public License as published by the -# Free Software Foundation; either version 2, or (at your option) any later -# version, or (b) the "Artistic License" which comes with this package. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the -# Artistic License for more details. -################################################################################ =head1 DATA OBJECT -This object holds data about the different types of elements that can be added -to a 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: @@ -66,13 +51,14 @@ C<< $data->add([$x1, $y1], [$x2, $y2], ..., [$xn, $yn]) >>. =item C<< $data->set_function >> -Configures a function to generate data points. C and C are are perl subroutines. +Configures a function to generate data points. C and C are MathObjects +or perl subroutines. $data->set_function( - sub_x => sub { return $_[0]; }, - sub_y => sub { return $_[0]**2; }, - min => -5, - max => 5, + Fx => Formula('t'), + Fy => Formula('t^2'), + min => -5, + max => 5, ); The number of steps used to generate the data is a style and needs to be set separately. @@ -155,43 +141,194 @@ sub style { return $self->{styles}{$style}; } +sub get_math_object { + my ($self, $formula, $var) = @_; + return $formula if ref($formula) eq 'CODE' || Value::isFormula($formula); + my $localContext = Parser::Context->current(\%main::context)->copy; + $localContext->variables->are($var => 'Real') unless $localContext->variables->get($var); + $formula = Value->Package('Formula')->new($localContext, $formula); + return $formula; +} + sub set_function { my ($self, %options) = @_; - $self->{function} = { - sub_x => sub { return $_[0]; }, - sub_y => sub { return $_[0]; }, - min => -5, - max => 5, - %options - }; - $self->style(steps => $self->{function}{steps}) if $self->{funciton}{steps}; + my $f = { Fx => 't', Fy => '', var => 't', min => -5, max => 5 }; + for my $key ('Fx', 'Fy', 'var', 'min', 'max') { + next unless defined $options{$key}; + $f->{$key} = $options{$key}; + delete $options{$key}; + } + return unless $f->{Fy}; + + $f->{Fx} = $self->get_math_object($f->{Fx}, $f->{var}); + $f->{Fy} = $self->get_math_object($f->{Fy}, $f->{var}); + $self->{function} = $f; + $self->style(%options) if %options; 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\-\.]/); +# Using MathObjects allows string values like 2pi/3, e^2, sqrt(2), etc. +sub str_to_real { + my ($self, $val) = @_; + return $val if !$val || $val !~ /[^\d\-\.]/; + my $localContext = Parser::Context->current(\%main::context); + return Value->Package('Real')->new($localContext, $val)->value; +} + +sub update_min_max { + my $self = shift; + my $f = $self->{function}; + $f->{min} = $self->str_to_real($f->{min}); + $f->{max} = $self->str_to_real($f->{max}); + return; +} + +# Takes a MathObject function string and replaces with JavaScript functions. +# Function takes either 'x' or 'y' for the corresponding coordinate function. +sub func_to_js { + my ($self, $coord) = @_; + my $f = $self->{function}; + my $MO = $coord eq 'x' ? $f->{Fx} : $coord eq 'y' ? $f->{Fy} : ''; + unless ($MO) { + warn "Invalid coordinate: $coord"; + return ''; + } + + # Ensure -x^2 gets print as -(x^2), since JavaScript finds this ambiguous. + my $extraParens = $MO->context->flag('showExtraParens'); + $MO->context->flags->set(showExtraParens => 2); + my $func = $MO->string; + $func =~ s/\s//g; + $MO->context->flags->set(showExtraParens => $extraParens); + + my $var = $f->{var}; + my %tokens = ( + sqrt => 'Math.sqrt', + cbrt => 'Math.cbrt', + hypot => 'Math.hypot', + norm => 'Math.hypot', + pow => 'Math.pow', + exp => 'Math.exp', + abs => 'Math.abs', + round => 'Math.round', + floor => 'Math.floor', + ceil => 'Math.ceil', + sign => 'Math.sign', + int => 'Math.trunc', + log => 'Math.ln', + ln => 'Math.ln', + cos => 'Math.cos', + sin => 'Math.sin', + tan => 'Math.tan', + acos => 'Math.acos', + arccos => 'Math.acos', + asin => 'Math.asin', + arcsin => 'Math.asin', + atan => 'Math.atan', + arctan => 'Math.atan', + atan2 => 'Math.atan2', + cosh => 'Math.cosh', + sinh => 'Math.sinh', + tanh => 'Math.tanh', + acosh => 'Math.acosh', + arccosh => 'Math.arccosh', + asinh => 'Math.asinh', + arcsinh => 'Math.asinh', + atanh => 'Math.atanh', + arctanh => 'Math.arctanh', + min => 'Math.min', + max => 'Math.max', + random => 'Math.random', + e => 'Math.E', + pi => 'Math.PI', + '^' => '**', + $var => $var + ); + + my $out = ''; + my $match; + while (length($func) > 0) { + if (($match) = ($func =~ m/^([A-Za-z]+|\^)/)) { + $func = substr($func, length($match)); + if ($tokens{$match}) { + $out .= $tokens{$match}; + } else { + warn "Unknown token $match in function."; + return ''; + } + } elsif (($match) = ($func =~ m/^([^A-Za-z^]+)/)) { + $func = substr($func, length($match)); + $out .= $match; + } else { # Shouldn't happen, but to stop an infinite loop for safety. + warn 'Unknown error parsing function.'; + last; + } + } + + return "function($var){ return $out; }"; +} + +sub stepsize { + my ($self, $steps) = @_; + my $f = $self->{function}; + $self->update_min_max; return ($f->{max} - $f->{min}) / $steps; } +sub get_generator_sub { + my ($self, $coord) = @_; + my $f = $self->{function}; + return $f->{"sub_$coord"} if $f->{"sub_$coord"}; + my $MO = $f->{"F$coord"}; + return $MO if ref($MO) eq 'CODE'; + if ($MO->string eq $f->{var}) { + $f->{"sub_$coord"} = sub { return $_[0]; } + } else { + my $sub = $MO->perlFunction(undef, [ $f->{var} ]); + $f->{"sub_$coord"} = sub { + my $x = shift; + my $y = Parser::Eval($sub, $x); + return defined $y ? $y->value : undef; + } + } + return $f->{"sub_$coord"}; +} + 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; + return if !$f || $self->size; # Only generate the data once. + my $steps = $self->style('steps') || 30; + my $dt = $self->stepsize($steps); my $t = $f->{min}; + my $sub_x = $self->get_generator_sub('x'); + my $sub_y = $self->get_generator_sub('y'); + for (0 .. $steps) { - $self->add(&{ $f->{sub_x} }($t), &{ $f->{sub_y} }($t)); + $self->add(&{$sub_x}($t), &{$sub_y}($t)); $t += $dt; } return; } +sub get_start_point { + my $self = shift; + return ($self->x(0), $self->y(0)) if $self->size; + my $f = $self->{function}; + my $sub_x = $self->get_generator_sub('x'); + my $sub_y = $self->get_generator_sub('y'); + return (&{$sub_x}($f->{min}), &{$sub_y}($f->{min})); +} + +sub get_end_point { + my $self = shift; + return ($self->x(-1), $self->y(-1)) if $self->size; + my $f = $self->{function}; + my $sub_x = $self->get_generator_sub('x'); + my $sub_y = $self->get_generator_sub('y'); + return (&{$sub_x}($f->{max}), &{$sub_y}($f->{max})); +} + sub _add { my ($self, $x, $y) = @_; return unless defined($x) && defined($y); diff --git a/lib/Plots/GD.pm b/lib/Plots/GD.pm index 0cbfe4b8d4..81f24c3256 100644 --- a/lib/Plots/GD.pm +++ b/lib/Plots/GD.pm @@ -1,17 +1,3 @@ -################################################################################ -# 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 @@ -29,19 +15,19 @@ use strict; use warnings; sub new { - my ($class, $pgplot) = @_; + my ($class, $plots) = @_; return bless { image => '', - pgplot => $pgplot, + plots => $plots, position => [ 0, 0 ], colors => {}, - image => GD::Image->new($pgplot->size) + image => GD::Image->new($plots->size) }, $class; } -sub pgplot { +sub plots { my $self = shift; - return $self->{pgplot}; + return $self->{plots}; } sub im { @@ -58,7 +44,7 @@ sub position { sub color { my ($self, $color) = @_; - $self->{colors}{$color} = $self->im->colorAllocate(@{ $self->pgplot->colors($color) }) + $self->{colors}{$color} = $self->im->colorAllocate(@{ $self->plots->colors($color) }) unless $self->{colors}{$color}; return $self->{colors}{$color}; } @@ -67,17 +53,17 @@ sub color { 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)); + my $plots = $self->plots; + my ($xmin, $xmax) = ($plots->axes->xaxis('min'), $plots->axes->xaxis('max')); + return int(($x - $xmin) * $plots->{width} / ($xmax - $xmin)); } sub im_y { my ($self, $y) = @_; return unless defined($y); - my $pgplot = $self->pgplot; - my ($ymin, $ymax) = ($pgplot->axes->yaxis('min'), $pgplot->axes->yaxis('max')); - return int(($ymax - $y) * ($pgplot->size)[1] / ($ymax - $ymin)); + my $plots = $self->plots; + my ($ymin, $ymax) = ($plots->axes->yaxis('min'), $plots->axes->yaxis('max')); + return int(($ymax - $y) * $plots->{height} / ($ymax - $ymin)); } sub moveTo { @@ -119,9 +105,9 @@ sub lineTo { # Draw functions / lines / arrows sub draw_data { my ($self, $pass) = @_; - my $pgplot = $self->pgplot; + my $plots = $self->plots; $pass = 0 unless $pass; - for my $data ($pgplot->data('function', 'dataset')) { + for my $data ($plots->data('function', 'dataset')) { $data->gen_data; my $n = $data->size - 1; my $x = $data->x; @@ -136,7 +122,7 @@ sub draw_data { if ($pass == 2) { my $r = int(3 + $width); my $start = $data->style('start_mark') || 'none'; - if ($start eq 'closed_circle') { + if ($start eq 'circle' || $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); @@ -145,7 +131,7 @@ sub draw_data { } my $end = $data->style('end_mark') || 'none'; - if ($end eq 'closed_circle') { + if ($end eq 'circle' || $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); @@ -232,10 +218,11 @@ sub draw_circle_stamp { sub draw { my $self = shift; - my $pgplot = $self->pgplot; - my $axes = $pgplot->axes; + my $plots = $self->plots; + my $axes = $plots->axes; my $grid = $axes->grid; - my $size = $pgplot->size; + my $width = $plots->{width}; + my $height = $plots->{height}; # Initialize image $self->im->interlaced('true'); @@ -245,7 +232,7 @@ sub draw { $self->draw_data(1); # Fill regions - for my $region ($pgplot->data('fill_region')) { + for my $region ($plots->data('fill_region')) { $self->im->fill($self->im_x($region->x(0)), $self->im_y($region->y(0)), $self->color($region->style('color'))); } @@ -362,16 +349,16 @@ sub draw { $self->draw_data(2); # Print Labels - for my $label ($pgplot->data('label')) { + for my $label ($plots->data('label')) { $self->draw_label($label->style('label'), $label->x(0), $label->y(0), %{ $label->style }); } # Draw stamps - for my $stamp ($pgplot->data('stamp')) { + for my $stamp ($plots->data('stamp')) { my $symbol = $stamp->style('symbol'); my $color = $stamp->style('color'); my $r = $stamp->style('radius') || 4; - if ($symbol eq 'closed_circle') { + if ($symbol eq 'circle' || $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); @@ -379,9 +366,9 @@ 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; + return $plots->ext eq 'gif' ? $self->im->gif : $self->im->png; } 1; diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm new file mode 100644 index 0000000000..f18aaebe51 --- /dev/null +++ b/lib/Plots/JSXGraph.pm @@ -0,0 +1,388 @@ + +=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::JSXGraph; + +use strict; +use warnings; + +sub new { + my ($class, $plots) = @_; + + $plots->insert_css('node_modules/jsxgraph/distrib/jsxgraph.css'); + $plots->insert_js('node_modules/jsxgraph/distrib/jsxgraphcore.js'); + + return bless { plots => $plots }, $class; +} + +sub plots { + my $self = shift; + return $self->{plots}; +} + +sub HTML { + my $self = shift; + return <{board} + +END_HTML +} + +sub get_color { + my ($self, $color) = @_; + return sprintf("#%02x%02x%02x", @{ $self->plots->colors($color) }); +} + +sub add_curve { + my ($self, $data) = @_; + my $linestyle = $data->style('linestyle') || ''; + return if $linestyle eq 'none'; + + my %linestyles; + if ($linestyle eq 'densely dashed') { + $linestyles{dash} = 4; + $linestyles{dashScale} = 1; + } elsif ($linestyle eq 'loosely dashed') { + $linestyles{dash} = 3; + $linestyles{dashScale} = 1; + } elsif ($linestyle =~ /dashed/) { + $linestyles{dash} = 1; + $linestyles{dashScale} = 1; + } elsif ($linestyle =~ /dotted/) { + $linestyles{dash} = 1; + } + + my $start = $data->style('start_mark') || ''; + my $end = $data->style('end_mark') || ''; + my $name = $self->{name}; + my $curve_name = $data->style('name'); + my $color = $self->get_color($data->style('color') || 'default_color'); + my $line_width = $data->style('width') || 2; + my $arrow_size = $line_width < 3 ? 12 / $line_width : 6; + my $fill = $data->style('fill') || 'none'; + my $fill_color = $self->get_color($data->style('fill_color') || 'default_color'); + my $fill_opacity = $data->style('fill_opacity') || 0.5; + my $JSXGraphOpts = $data->style('JSXGraphOpts') || {}; + my $plotOptions = Mojo::JSON::encode_json({ + strokeColor => $color, + strokeWidth => $line_width, + $start eq 'arrow' ? (firstArrow => { type => 5, size => $arrow_size }) : (), + $end eq 'arrow' ? (lastArrow => { type => 5, size => $arrow_size }) : (), + $fill eq 'self' ? (fillColor => $fill_color, fillOpacity => $fill_opacity) : (), + %linestyles, + %$JSXGraphOpts, + }); + + my $type = 'curve'; + my $data_points; + if ($data->name eq 'function' && ref($data->{function}{Fx}) ne 'CODE' && ref($data->{function}{Fy}) ne 'CODE') { + my $f = $data->{function}; + $data->update_min_max; + if ($f->{var} eq $f->{Fx}->string) { + my $min = $data->style('continue_left') ? '' : $f->{min}; + my $max = $data->style('continue_right') ? '' : $f->{max}; + $type = 'functiongraph'; + $data_points = '[' . $data->func_to_js('y') . ", $min, $max]"; + } else { + $data_points = '[' . $data->func_to_js('x') . ', ' . $data->func_to_js('y') . ", $f->{min}, $f->{max}]"; + } + } else { + $data->gen_data; + $data_points = '[[' . join(',', $data->x) . '],[' . join(',', $data->y) . ']]'; + } + + $self->{JS} .= "\n\t\t"; + if ($curve_name) { + $self->{JS} .= "const curve_${curve_name}_$name = "; + } + $self->{JS} .= "board_$name.create('$type', $data_points, $plotOptions);"; + $self->add_point($data, $data->get_start_point, $line_width, $start, $color) if $start =~ /circle/; + $self->add_point($data, $data->get_end_point, $line_width, $end, $color) if $end =~ /circle/; + if ($curve_name && $fill ne 'none' && $fill ne 'self') { + my $fill_min = $data->str_to_real($data->style('fill_min')); + my $fill_max = $data->str_to_real($data->style('fill_max')); + my $fillOptions = Mojo::JSON::encode_json({ + strokeColor => $color, + strokeWidth => 0, + fillColor => $fill_color, + fillOpacity => $fill_opacity, + }); + + if ($fill eq 'xaxis') { + $self->{JSend} .= + "\n\t\tconst fill_${curve_name}_$name = board_$name.create('curve', [[], []], $fillOptions);\n" + . "\t\tfill_${curve_name}_$name.updateDataArray = function () {\n" + . "\t\t\tconst points = curve_${curve_name}_$name.points"; + if (defined $fill_min && defined $fill_max) { + $self->{JSend} .= + ".filter(p => {\n" + . "\t\t\t\treturn p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false\n" + . "\t\t\t})"; + } + $self->{JSend} .= + ";\n\t\t\tthis.dataX = points.map( p => p.usrCoords[1] );\n" + . "\t\t\tthis.dataY = points.map( p => p.usrCoords[2] );\n" + . "\t\t\tthis.dataX.push(points[points.length - 1].usrCoords[1], " + . "points[0].usrCoords[1], points[0].usrCoords[1]);\n" + . "\t\t\tthis.dataY.push(0, 0, points[0].usrCoords[2]);\n" + . "\t\t};\n" + . "\t\tboard_$name.update();"; + } else { + $self->{JSend} .= + "\n\t\tconst fill_${curve_name}_$name = board_$name.create('curve', [[], []], $fillOptions);\n" + . "\t\tfill_${curve_name}_$name.updateDataArray = function () {\n" + . "\t\t\tconst points1 = curve_${curve_name}_$name.points"; + if (defined $fill_min && defined $fill_max) { + $self->{JSend} .= + ".filter(p => {\n" + . "\t\t\t\treturn p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false\n" + . "\t\t\t})"; + } + $self->{JSend} .= ";\n\t\t\tconst points2 = curve_${fill}_$name.points"; + if (defined $fill_min && defined $fill_max) { + $self->{JSend} .= + ".filter(p => {\n" + . "\t\t\t\treturn p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false\n" + . "\t\t\t})"; + } + $self->{JSend} .= + ";\n\t\t\tthis.dataX = points1.map( p => p.usrCoords[1] ).concat(" + . "points2.map( p => p.usrCoords[1] ).reverse());\n" + . "\t\t\tthis.dataY = points1.map( p => p.usrCoords[2] ).concat(" + . "points2.map( p => p.usrCoords[2] ).reverse());\n" + . "\t\t\tthis.dataX.push(points1[0].usrCoords[1]);\n" + . "\t\t\tthis.dataY.push(points1[0].usrCoords[2]);\n" + . "\t\t};\n" + . "\t\tboard_$name.update();"; + } + } +} + +sub add_point { + my ($self, $data, $x, $y, $size, $mark, $color) = @_; + my $fill = $color; + my $name = $self->{name}; + + if ($mark eq 'circle' || $mark eq 'closed_circle') { + $mark = 'o'; + } elsif ($mark eq 'open_circle') { + $mark = 'o'; + $fill = '#ffffff'; + } elsif ($mark eq 'square') { + $mark = '[]'; + } elsif ($mark eq 'open_square') { + $mark = '[]'; + $fill = '#ffffff'; + } elsif ($mark eq 'plus') { + $mark = '+'; + } elsif ($mark eq 'times') { + $mark = 'x'; + } elsif ($mark eq 'bar') { + $mark = '|'; + } elsif ($mark eq 'dash') { + $mark = '-'; + } elsif ($mark eq 'triangle') { + $mark = '^'; + } elsif ($mark eq 'open_triangle') { + $mark = '^'; + $fill = '#ffffff'; + } elsif ($mark eq 'diamond') { + $mark = '<>'; + } elsif ($mark eq 'open_diamond') { + $mark = '<>'; + $fill = '#ffffff'; + } else { + return; + } + + my $JSXGraphOpts = $data->style('JSXGraphOpts') || {}; + my $pointOptions = Mojo::JSON::encode_json({ + fixed => 1, + withLabel => 0, + face => $mark, + strokeColor => $color, + fillColor => $fill, + size => $size, + %$JSXGraphOpts + }); + + $self->{JS} .= "\n\t\tboard_$name.create('point', [$x, $y], $pointOptions);"; +} + +sub add_points { + my ($self, $data) = @_; + my $mark = $data->style('marks'); + return if !$mark || $mark eq 'none'; + + my $size = $data->style('mark_size') || $data->style('width') || 3; + my $color = $self->get_color($data->style('color') || 'default_color'); + + for (0 .. $data->size - 1) { + $self->add_point($data, $data->x($_), $data->y($_), $size, $mark, $color); + } +} + +sub init_graph { + my $self = shift; + my $plots = $self->plots; + my $axes = $plots->axes; + my $grid = $axes->grid; + my $name = $self->{name}; + my $title = $axes->style('title'); + my $xaxis_loc = $axes->xaxis('location'); + my $yaxis_loc = $axes->yaxis('location'); + my $xaxis_pos = $axes->xaxis('position'); + my $yaxis_pos = $axes->yaxis('position'); + my $show_grid = $axes->style('show_grid'); + my ($xmin, $ymin, $xmax, $ymax) = $axes->bounds; + my ($width, $height) = $plots->size; + my $style = 'display: inline-block; margin: 5px; text-align: center;'; + my $JSXGraphAxesOpts = $axes->style('JSXGraphOpts') || {}; + my $JSXGraphXAxisOpts = $axes->xaxis('JSXGraphOpts') || {}; + my $JSXGraphYAxisOpts = $axes->yaxis('JSXGraphOpts') || {}; + + # Adjust bounding box to add padding for axes at edge of graph. + $xaxis_loc = 'bottom' if $xaxis_loc eq 'box'; + $yaxis_loc = 'left' if $yaxis_loc eq 'box'; + $xmin -= 0.11 * ($xmax - $xmin) if $yaxis_loc eq 'left' || $xmin == $xaxis_pos; + $xmax += 0.11 * ($xmax - $xmin) if $yaxis_loc eq 'right' || $xmax == $xaxis_pos; + $ymin -= 0.11 * ($ymax - $ymin) if $xaxis_loc eq 'bottom' || $ymin == $yaxis_pos; + $ymax += 0.11 * ($ymax - $ymin) if $xaxis_loc eq 'top' || $ymax == $yaxis_pos; + + $title = "$title" if $title; + $self->{board} = <$title +
+ +END_HTML + + my $JSXOptions = Mojo::JSON::encode_json({ + title => $title || 'Graph', + description => $plots->{ariaDescription}, + boundingBox => [ $xmin, $ymax, $xmax, $ymin ], + axis => 0, + showNavigation => 0, + showCopyright => 0, + %$JSXGraphAxesOpts + }); + my $XAxisOptions = Mojo::JSON::encode_json({ + name => $axes->xaxis('label'), + withLabel => 1, + position => $xaxis_loc eq 'middle' ? 'sticky' : 'fixed', + anchor => $xaxis_loc eq 'top' ? 'left' : $xaxis_loc eq 'bottom' ? 'right' : 'right left', + visible => $axes->xaxis('visible') ? 1 : 0, + label => { + position => 'rt', + offset => [ -10, 10 ] + }, + ticks => { + drawZero => 1, + insertTicks => 0, + ticksDistance => $axes->xaxis('tick_delta'), + majorHeight => $show_grid && $axes->xaxis('major') ? -1 : 10, + minorTicks => $axes->xaxis('minor'), + minorHeight => $show_grid ? -1 : 7, + }, + %$JSXGraphXAxisOpts + }); + my $YAxisOptions = Mojo::JSON::encode_json({ + name => $axes->yaxis('label'), + withLabel => 1, + position => $yaxis_loc eq 'center' ? 'sticky' : 'fixed', + anchor => $yaxis_loc eq 'center' ? 'right left' : $yaxis_loc, + visible => $axes->yaxis('visible') ? 1 : 0, + label => { + position => 'rt', + offset => [ 10, -10 ] + }, + ticks => { + drawZero => 1, + insertTicks => 0, + ticksDistance => $axes->yaxis('tick_delta'), + majorHeight => $show_grid && $axes->yaxis('major') ? -1 : 10, + minorTicks => $axes->yaxis('minor'), + minorHeight => $show_grid ? -1 : 7, + }, + %$JSXGraphYAxisOpts + }); + + $self->{JSend} = ''; + $self->{JS} = <plots; + my $name = $plots->get_image_name =~ s/-/_/gr; + $self->{name} = $name; + + $self->init_graph; + + # Plot Data + for my $data ($plots->data('function', 'dataset')) { + $self->add_curve($data); + $self->add_points($data); + } + + # Stamps + for my $stamp ($plots->data('stamp')) { + my $mark = $stamp->style('symbol'); + next unless $mark; + + my $color = $self->get_color($stamp->style('color') || 'default_color'); + my $x = $stamp->x(0); + my $y = $stamp->y(0); + my $size = $stamp->style('radius') || 4; + + $self->add_point($stamp, $x, $y, $size, $mark, $color); + } + + # Labels + for my $label ($plots->data('label')) { + my $str = $label->style('label'); + my $x = $label->x(0); + my $y = $label->y(0); + my $color = $self->get_color($label->style('color') || 'default_color'); + my $fontsize = $label->style('fontsize') || 'medium'; + my $orientation = $label->style('orientation') || 'horizontal'; + 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' : ''; + my $JSXGraphOpts = $label->style('JSXGraphOpts') || {}; + my $textOptions = Mojo::JSON::encode_json({ + fontSize => { tiny => 8, small => 10, medium => 12, large => 14, giant => 16 }->{$fontsize}, + rotate => $orientation eq 'vertical' ? 90 : 0, + strokeColor => $color, + anchorX => $h_align eq 'center' ? 'middle' : $h_align, + anchorY => $v_align, + cssStyle => 'padding: 3px;', + %$JSXGraphOpts, + }); + + $self->{JS} .= "\n\t\tboard_$name.create('text', [$x, $y, '$str'], $textOptions);"; + } + + # JSXGraph only produces HTML graphs and uses TikZ for hadrcopy. + return $self->HTML; +} + +1; diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm index 6a314be2f5..15a1259d80 100644 --- a/lib/Plots/Plot.pm +++ b/lib/Plots/Plot.pm @@ -1,17 +1,3 @@ -################################################################################ -# 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 @@ -29,28 +15,49 @@ use warnings; use Plots::Axes; use Plots::Data; use Plots::Tikz; +use Plots::JSXGraph; use Plots::GD; sub new { my ($class, $pg, %options) = @_; - my $size = $main::envir{onTheFlyImageSize} || 500; + my $size = $main::envir{onTheFlyImageSize} || 350; my $self = bless { - pg => $pg, - imageName => {}, - type => 'Tikz', - ext => 'svg', - size => [ $size, $size ], - axes => Plots::Axes->new, - colors => {}, - data => [], + pg => $pg, + imageName => {}, + width => $size, + height => $size, + tex_size => 600, + ariaDescription => 'Generated graph', + axes => Plots::Axes->new, + colors => {}, + data => [], %options }, $class; $self->color_init; + $self->image_type('JSXGraph'); 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 +92,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 +138,17 @@ 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 'jsxgraph') { + $self->{type} = 'JSXGraph'; + @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,73 +156,51 @@ 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. -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, + Fx => $Fx, + Fy => $Fy, + var => $var, + min => $min, + max => $max, + color => 'default_color', + width => 2, + dashed => 0, + tikz_smooth => 1, + @rest ); + $self->add_data($data); return $data; } @@ -232,11 +220,11 @@ sub parse_function_string { 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 = '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 = 'circle'; } elsif ($end eq '}') { $end = 'arrow'; } else { $end = 'none'; } @@ -285,7 +273,7 @@ sub _add_dataset { } $data->style( color => 'default_color', - width => 1, + width => 2, @points ); @@ -349,7 +337,7 @@ sub _add_stamp { $data->style( color => 'default_color', size => 4, - symbol => 'closed_circle', + symbol => 'circle', @options ); $self->add_data($data); @@ -367,10 +355,12 @@ 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 'JSXGraph') { + $image = Plots::JSXGraph->new($self); + } elsif ($type eq 'GD') { + $image = Plots::GD->new($self); } else { warn "Undefined image type: $type"; return; diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index b9a884f490..ef2d4c2b75 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -1,17 +1,3 @@ -################################################################################ -# 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 @@ -27,22 +13,22 @@ use strict; use warnings; sub new { - my ($class, $pgplot) = @_; + my ($class, $plots) = @_; my $image = LaTeXImage->new; $image->environment('tikzpicture'); $image->svgMethod($main::envir{latexImageSVGMethod} // 'dvisvgm'); $image->convertOptions($main::envir{latexImageConvertOptions} // { input => {}, output => {} }); - $image->ext($pgplot->ext); + $image->ext($plots->ext); $image->tikzLibraries('arrows.meta,plotmarks'); $image->texPackages(['pgfplots']); $image->addToPreamble('\pgfplotsset{compat=1.18}\usepgfplotslibrary{fillbetween}'); - return bless { image => $image, pgplot => $pgplot, colors => {} }, $class; + return bless { image => $image, plots => $plots, colors => {} }, $class; } -sub pgplot { +sub plots { my $self = shift; - return $self->{pgplot}; + return $self->{plots}; } sub im { @@ -53,18 +39,37 @@ sub im { sub get_color { my ($self, $color) = @_; return '' if $self->{colors}{$color}; - my ($r, $g, $b) = @{ $self->pgplot->colors($color) }; + my ($r, $g, $b) = @{ $self->plots->colors($color) }; $self->{colors}{$color} = 1; return "\\definecolor{$color}{RGB}{$r,$g,$b}\n"; } +sub get_mark { + my ($self, $mark) = @_; + return { + circle => '*', + closed_circle => '*', + open_circle => 'o', + square => 'square*', + open_square => 'square', + plus => '+', + times => 'x', + bar => '|', + dash => '-', + triangle => 'triangle*', + open_triangle => 'triangle', + diamond => 'diamond*', + open_diamond => 'diamond', + }->{$mark}; +} + sub configure_axes { - my $self = shift; - my $pgplot = $self->pgplot; - my $axes = $pgplot->axes; - my $grid = $axes->grid; + my $self = shift; + my $plots = $self->plots; + my $axes = $plots->axes; + my $grid = $axes->grid; my ($xmin, $ymin, $xmax, $ymax) = $axes->bounds; - my ($axes_height, $axes_width) = $pgplot->size; + my ($axes_width, $axes_height) = $plots->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; @@ -80,13 +85,17 @@ sub configure_axes { my $grid_style = $axes->style('grid_style'); my $xlabel = $axes->xaxis('label'); my $axis_x_line = $axes->xaxis('location'); + my $axis_x_pos = $axes->xaxis('position'); my $ylabel = $axes->yaxis('label'); my $axis_y_line = $axes->yaxis('location'); + my $axis_y_pos = $axes->yaxis('position'); 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" : ''; + $axis_x_pos = $axis_x_pos ? ",\n\t\t\taxis x line shift=" . (-$axis_x_pos) : ''; + $axis_y_pos = $axis_y_pos ? ",\n\t\t\taxis y line shift=" . (-$axis_y_pos) : ''; unless ($axes->xaxis('visible')) { $xlabel = ''; @@ -107,8 +116,8 @@ sub configure_axes { [ height=$axes_height, width=$axes_width, - ${axis_on_top}axis x line=$axis_x_line, - axis y line=$axis_y_line, + ${axis_on_top}axis x line=$axis_x_line$axis_x_pos, + axis y line=$axis_y_line$axis_y_pos, xlabel={$xlabel}, ylabel={$ylabel}, title={$title}, @@ -149,6 +158,7 @@ sub get_plot_opts { my $fill_color = $data->style('fill_color') || 'default_color'; my $fill_opacity = $data->style('fill_opacity') || 0.5; my $tikzOpts = $data->style('tikzOpts') || ''; + my $smooth = $data->style('tikz_smooth') ? 'smooth, ' : ''; if ($start =~ /circle/) { $start = '{Circle[sep=-1.196825pt -1.595769' . ($start eq 'open_circle' ? ', open' : '') . ']}'; @@ -165,21 +175,8 @@ sub get_plot_opts { $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" : ''; + $marks = $self->get_mark($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"; @@ -189,12 +186,12 @@ sub get_plot_opts { $name = ", name path=$name" if $name; $tikzOpts = ", $tikzOpts" if $tikzOpts; - return "color=$color, line width=${width}pt$marks$linestyle$end_markers$fill$name$tikzOpts"; + return "${smooth}color=$color, line width=${width}pt$marks$linestyle$end_markers$fill$name$tikzOpts"; } sub draw { - my $self = shift; - my $pgplot = $self->pgplot; + my $self = shift; + my $plots = $self->plots; # Reset colors just in case. $self->{colors} = {}; @@ -203,7 +200,7 @@ sub draw { my $tikzCode = $self->configure_axes; # Plot Data - for my $data ($pgplot->data('function', 'dataset')) { + for my $data ($plots->data('function', 'dataset')) { $data->gen_data; my $n = $data->size; my $color = $data->style('color') || 'default_color'; @@ -215,34 +212,21 @@ sub draw { $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') || ''; + my $opacity = $data->style('fill_opacity') || 0.5; + my $fill_min = $data->style('fill_min'); + my $fill_max = $data->style('fill_max'); + my $fill_range = defined $fill_min && defined $fill_max ? ", soft clip={domain=$fill_min:$fill_max}" : ''; $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') }; + for my $stamp ($plots->data('stamp')) { + my $mark = $self->get_mark($stamp->style('symbol')); + next unless $mark; + my $color = $stamp->style('color') || 'default_color'; my $x = $stamp->x(0); my $y = $stamp->y(0); @@ -252,7 +236,7 @@ sub draw { } # Labels - for my $label ($pgplot->data('label')) { + for my $label ($plots->data('label')) { my $str = $label->style('label'); my $x = $label->x(0); my $y = $label->y(0); @@ -279,9 +263,9 @@ sub draw { } $tikzCode .= '\end{axis}' . "\n"; - $pgplot->{tikzCode} = $tikzCode; + $plots->{tikzCode} = $tikzCode; $self->im->tex($tikzCode); - return $pgplot->{tikzDebug} ? '' : $self->im->draw; + return $plots->{tikzDebug} ? '' : $self->im->draw; } 1; diff --git a/macros/core/PGbasicmacros.pl b/macros/core/PGbasicmacros.pl index c03afa0ca7..52c1862733 100644 --- a/macros/core/PGbasicmacros.pl +++ b/macros/core/PGbasicmacros.pl @@ -49,7 +49,7 @@ sub _PGbasicmacros_init { # This is initializes the remaining variables in the runtime main:: compartment. - main::PG_restricted_eval( <<'EndOfFile'); + main::PG_restricted_eval(<<'EndOfFile'); $displayMode = $displayMode; $main::PAR = PAR(); @@ -2838,9 +2838,26 @@ sub image { ); next; } + if (ref $image_item eq 'Plots::Plot') { + # Update image attributes as needed. + $image_item->{width} = $width if $out_options{width}; + $image_item->{height} = $height if $out_options{height}; + $image_item->{tex_size} = $tex_size if $out_options{tex_size}; + $image_item->{ariaDescription} = shift @alt_list if $out_options{alt}; + + if ($image_item->ext eq 'html') { + push(@output_list, $image_item->draw); + next; + } + + # Use Plots default size and not the 200 default size of image. + $width_attrib = qq{ width="$image_item->{width}"} if $width_attrib; + $height_attrib = qq{ height="$image_item->{height}"} if $height_attrib; + $width_ratio = 0.001 * $image_item->{tex_size}; + } $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..8012d061b1 100644 --- a/macros/graph/plots.pl +++ b/macros/graph/plots.pl @@ -1,17 +1,3 @@ -################################################################################ -# 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 @@ -19,37 +5,41 @@ =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, and the legacy C +graphics format are available. Default is to use C for HTML output and +C for hardcopy. + +Note, due to differences in features between C and C, not all +options work with both. =head1 USAGE -First create a PGplot object: +First create a Plots object: loadMacros('plots.pl'); $plot = Plot(); -Configure the L: +Configure the L: $plot->axes->xaxis( - min => 0, - max => 10, - ticks => [0, 2, 4, 6, 8, 10], - label => '\(t\)', + min => 0, + max => 10, + tick_delta => 2, + label => '\(t\)', ); $plot->axes->yaxis( - min => 0, - max => 500, - ticks => [0, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500], - label => '\(h(t)\)' + min => 0, + max => 500, + tick_delta => 50, + 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); + $plot->add_function('-16t^2 + 80t + 384', 't', 0, 8, color => 'blue', width => 3); Insert the graph into the problem. @@ -59,8 +49,8 @@ =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, +A plot consists of multiple L, which define datasets, functions, +and labels to add to the graph. Data objects should be created though the Plots object, but can be access directly if needed =head2 DATASETS @@ -70,7 +60,7 @@ =head2 DATASETS can be added individually, or multiple at once as shown: # Add a single dataset - $plot->add_dataset([$x1, $y1], [$x2, $y2], ..., [$xn, $yn], @options)> + $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], @@ -117,23 +107,6 @@ =head2 PLOT FUNCTIONS # 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" @@ -162,6 +135,26 @@ =head2 PLOT FUNCTIONS '8-2x for x in [2.5,5}' ); +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 + ); + +It is prefered to use strings or MathObjects instead of perl subroutiens when +using the default C output. + =head2 DATASET OPTIONS The following are the options that can be used to configure how datasets and functions are plotted. @@ -187,9 +180,9 @@ =head2 DATASET OPTIONS =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' +can be one of 'none', 'circle' (or 'closed_circle'), 'open_circle', 'square', +'open_square', 'plus', 'times', 'bar', 'dash', 'triangle', 'open_triangle', +'diamond', or 'open_diamond'. Default: 'none' =item mark_size @@ -209,7 +202,9 @@ =head2 DATASET OPTIONS =item name -The name assigned to the dataset to reference it for filling (see below). +The name assigned to the curve to reference it for filling (see below). +Each curve used to fill between curves or the xaxis must have a unique name. +Default: undefined =item fill @@ -229,6 +224,16 @@ =head2 DATASET OPTIONS fill_opacity => 0.1, ); +The following fills the area under the curve y = 4 - x^2 over its whole domain. + + $plot->add_function('4 - x^2', 'x', -2, 2, + color => 'blue', + name => 'A', + fill => 'xaxis', + fill_color => 'red', + fill_opacity => 0.2 + ); + 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: @@ -240,9 +245,10 @@ =head2 DATASET OPTIONS color => 'blue', name => 'B', fill => 'A', - fill_opacity => 0.2, - fill_range => '-2,2', + fill_min => -2, + fill_max => 2, fill_color => 'green', + fill_opacity => 0.2, ); =item fill_color @@ -253,21 +259,45 @@ =head2 DATASET OPTIONS A number between 0 and 1 giving the opacity of the fill. Default: 0.5 -=item fill_range +=item fill_min, fill_max -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: '' +The minmum and maxium x-value to fill between. If either of these are +not defined, then the fill will use the full domain of the function. +Default: undefined =item steps This defines the number of points to generate for a dataset from a function. -Default: 20. +Default: 30. + +=item tikz_smooth + +Either 0 or 1 to add the TikZ option "smooth" to the plot, which will smooth +out the plot making it look good with fewer steps. By default this is turned +on for functions but off for other datasets. This alters the look of the plot +and can mess with fills. For functions you will need to explicitly turn it +off in cases it has undesirable side effects. + +=item continue_left + +If set to 1, the graph of a non-parametric function using JSXGraph will keep going +to the left beyond the minimum bound. This allows zooming out or panning the graph +to the left. Default: 0 + +=item continue_right + +If set to 1, the graph of a non-parametric function using JSXGraph will keep going +to the right beyond the maximum bound. This allows zooming out or panning the graph +to the right. Default: 0 =item tikzOpts Additional pgfplots C<\addplot> options to be added to the tikz output. +=item JSXGraphOpts + +A hash reference of options to pass to be added to the JSXGraph output. + =back =head2 LABELS @@ -397,9 +427,8 @@ =head1 TIKZ DEBUGGING $plot->{tikzDebug} = 1; $image = insertGraph($plot); - $tikzCode = $plot->tikz_code; BEGIN_PGML - [$tikzCode]* + [@ $plot->tikz_code @]* END_PGML =cut diff --git a/macros/graph/unionImage.pl b/macros/graph/unionImage.pl index c859b0c831..ba389a266e 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; } From bc02e1c53d6db278ad51cfde38b77fbfe1d77723 Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Fri, 6 Jun 2025 10:57:30 -0600 Subject: [PATCH 06/19] Add show_ticks and tick_labels option to each axis. These options control if ticks numbers are shown or if tick lines are shown. This allows disabling all ticks if needed. --- lib/Plots/Axes.pm | 52 +++++++++++++++++++++++++---------------- lib/Plots/JSXGraph.pm | 18 ++++++++------- lib/Plots/Tikz.pm | 54 ++++++++++++++++++++++--------------------- 3 files changed, 70 insertions(+), 54 deletions(-) diff --git a/lib/Plots/Axes.pm b/lib/Plots/Axes.pm index 9a3e25602f..a5fc1bd2bf 100644 --- a/lib/Plots/Axes.pm +++ b/lib/Plots/Axes.pm @@ -83,12 +83,11 @@ The minimum value the axis shows. Default is -5. The maximum value the axis shows. Default is 5. -=item ticks +=item tick_num -An array which lists the major tick marks. If this array is empty, the ticks are -generated using either C or C. Note, JSXGraph doesn't support -this option, be sure to set C or C if using JSXGraph. -Default is C<[]>. +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 tick_delta @@ -97,11 +96,22 @@ This distance is then used to generate the tick marks if the ticks array is empt If this is set to 0, this distance is set by using the number of ticks, C. Default is 0. -=item tick_num +=item ticks -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. +An array which lists the major tick marks. If this array is empty, the ticks are +generated using either C or C. Note, JSXGraph doesn't support +this option, be sure to set C or C if using JSXGraph. +Default is C<[]>. + +=item tick_labels + +This can be either 1 (show) or 0 (don't show) the labels for the major ticks. +Default: 1 + +=item show_ticks + +This can be either 1 (show) or 0 (don't show) the tick lines. If ticks are +not shown then tick labels won't be shown either. Default: 1 =item label @@ -218,17 +228,19 @@ sub new { sub axis_defaults { my ($self, $axis) = @_; return ( - visible => 1, - min => -5, - max => 5, - label => $axis eq 'y' ? '\(y\)' : '\(x\)', - location => $axis eq 'y' ? 'center' : 'middle', - position => 0, - ticks => undef, - tick_delta => 0, - tick_num => 5, - major => 1, - minor => 3, + visible => 1, + min => -5, + max => 5, + label => $axis eq 'y' ? '\(y\)' : '\(x\)', + location => $axis eq 'y' ? 'center' : 'middle', + position => 0, + ticks => undef, + tick_labels => 1, + show_ticks => 1, + tick_delta => 0, + tick_num => 5, + major => 1, + minor => 3, ); } diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index f18aaebe51..8c4d596e08 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -291,12 +291,13 @@ END_HTML offset => [ -10, 10 ] }, ticks => { - drawZero => 1, + drawLabels => $axes->xaxis('tick_labels') && $axes->xaxis('show_ticks') ? 1 : 0, + drawZero => $show_grid && $axes->xaxis('major') ? 1 : 0, insertTicks => 0, ticksDistance => $axes->xaxis('tick_delta'), - majorHeight => $show_grid && $axes->xaxis('major') ? -1 : 10, - minorTicks => $axes->xaxis('minor'), - minorHeight => $show_grid ? -1 : 7, + majorHeight => $axes->xaxis('show_ticks') ? ($show_grid && $axes->xaxis('major') ? -1 : 10) : 0, + minorTicks => $axes->xaxis('major') ? $axes->xaxis('minor') : 0, + minorHeight => $axes->xaxis('show_ticks') ? ($show_grid ? -1 : 7) : 0, }, %$JSXGraphXAxisOpts }); @@ -311,12 +312,13 @@ END_HTML offset => [ 10, -10 ] }, ticks => { - drawZero => 1, + drawLabels => $axes->yaxis('tick_labels') && $axes->yaxis('show_ticks') ? 1 : 0, + drawZero => $show_grid && $axes->yaxis('major') ? 1 : 0, insertTicks => 0, ticksDistance => $axes->yaxis('tick_delta'), - majorHeight => $show_grid && $axes->yaxis('major') ? -1 : 10, - minorTicks => $axes->yaxis('minor'), - minorHeight => $show_grid ? -1 : 7, + majorHeight => $axes->yaxis('show_ticks') ? ($show_grid && $axes->yaxis('major') ? -1 : 10) : 0, + minorTicks => $axes->yaxis('major') ? $axes->yaxis('minor') : 0, + minorHeight => $axes->yaxis('show_ticks') ? ($show_grid ? -1 : 7) : 0, }, %$JSXGraphYAxisOpts }); diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index ef2d4c2b75..850e387fdc 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -70,30 +70,32 @@ sub configure_axes { my $grid = $axes->grid; my ($xmin, $ymin, $xmax, $ymax) = $axes->bounds; my ($axes_width, $axes_height) = $plots->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 $axis_x_pos = $axes->xaxis('position'); - my $ylabel = $axes->yaxis('label'); - my $axis_y_line = $axes->yaxis('location'); - my $axis_y_pos = $axes->yaxis('position'); - 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" : ''; + my $show_grid = $axes->style('show_grid'); + my $xmajor = $show_grid && $grid->{xmajor} ? 'true' : 'false'; + my $xminor_num = $grid->{xminor}; + my $xminor = $show_grid && $xminor_num > 0 ? 'true' : 'false'; + my $ymajor = $show_grid && $grid->{ymajor} ? 'true' : 'false'; + my $yminor_num = $grid->{yminor}; + my $yminor = $show_grid && $yminor_num > 0 ? 'true' : 'false'; + my $xticks = $axes->xaxis('show_ticks') ? '{' . join(',', @{ $grid->{xticks} }) . '}' : 'none'; + my $yticks = $axes->yaxis('show_ticks') ? '{' . join(',', @{ $grid->{yticks} }) . '}' : 'none'; + my $xtick_labels = $axes->xaxis('tick_labels') ? '' : "\n\t\t\txticklabel=\\empty,"; + my $ytick_labels = $axes->yaxis('tick_labels') ? '' : "\n\t\t\tyticklabel=\\empty,"; + 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 $axis_x_pos = $axes->xaxis('position'); + my $ylabel = $axes->yaxis('label'); + my $axis_y_line = $axes->yaxis('location'); + my $axis_y_pos = $axes->yaxis('position'); + 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" : ''; $axis_x_pos = $axis_x_pos ? ",\n\t\t\taxis x line shift=" . (-$axis_x_pos) : ''; $axis_y_pos = $axis_y_pos ? ",\n\t\t\taxis y line shift=" . (-$axis_y_pos) : ''; @@ -121,8 +123,8 @@ sub configure_axes { xlabel={$xlabel}, ylabel={$ylabel}, title={$title}, - xtick={$xticks}, - ytick={$yticks}, + xtick=$xticks,$xtick_labels + ytick=$yticks,$ytick_labels xmajorgrids=$xmajor, xminorgrids=$xminor, minor x tick num=$xminor_num, From 0942a53f9de49e824b6191b66aeeb2657b603ecf Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Sun, 8 Jun 2025 23:26:27 -0600 Subject: [PATCH 07/19] Add ability to plot vector fields with Plots. * Refactors how functions are stored to allow using two variable functions for vector fields. * Adds the ability to use function strings with PGF formulas for TikZ output. This is now the default with TikZ output, instead of generating the points using perl. In addition this allows generation of vector fields with TikZ. * Add the add_vectorfield function to the Plots::Plot object to allow creating of vector fields for both JSXGraph and Tikz output. * Update VectorField2D.pl to use this if provided a Plots::Plot object. --- lib/Plots/Data.pm | 255 ++++++++++++++++++++++++---------- lib/Plots/JSXGraph.pm | 58 ++++++-- lib/Plots/Plot.pm | 29 +++- lib/Plots/Tikz.pm | 64 ++++++++- macros/graph/VectorField2D.pl | 20 ++- macros/graph/plots.pl | 65 ++++++++- 6 files changed, 393 insertions(+), 98 deletions(-) diff --git a/lib/Plots/Data.pm b/lib/Plots/Data.pm index e8fe397c8b..d20b243860 100644 --- a/lib/Plots/Data.pm +++ b/lib/Plots/Data.pm @@ -51,19 +51,37 @@ C<< $data->add([$x1, $y1], [$x2, $y2], ..., [$xn, $yn]) >>. =item C<< $data->set_function >> -Configures a function to generate data points. C and C are MathObjects -or perl subroutines. +Configures a function to generate data points. C and C are strings (which are +turned into MathObjects), MathObjects, or per subroutines. The core function data is +stored in the C<< $data->{function} >> hash, though other data is stored as a style. $data->set_function( - Fx => Formula('t'), - Fy => Formula('t^2'), - min => -5, - max => 5, + Fx => Formula('t'), + Fy => Formula('t^2'), + var => 't', + min => -5, + max => 5, + steps => 50, ); -The number of steps used to generate the data is a style and needs to be set separately. +This is also used to set a two variable function (used for slope or vector fields): - $data->style(steps => 50); + $data->set_function( + Fx => Formula('x^2 + y^2'), + Fy => Formula('x - y'), + xvar => 'x', + yvar => 'y', + xmin => -5, + xmax => 5, + ymin => -5, + ymax => 5 + xsteps => 15, + ysteps => 15, + ); + +Note a function always stores the coordinate variables as C, C, C, etc. +When using a single variable function just use the x-coordinate values. C, C, C, +C, will set the x-coordinate values and will override any C, C, etc settings. =item C<< $data->gen_data >> @@ -87,6 +105,31 @@ to add / change the styles. $data->style(color => 'blue', width => 3); +=item C<< $str = $data->function_string($coord, $type, $nvars); >> + +Takes a MathObject function string and replaces the function with either +a JavaScript or PGF function string. If the function contains any function +tokens not supported, a warning and empty string is returned. + + $coord 'x' or 'y' coordinate function. + $type 'js' or 'PGF' (falls back to js for any input except 'PGF'). + $nvars 1 (single variable functions) or 2 (used for slope/vector fields). + +=item C<< $data->update_min_max >> + +Updates a functions C, C, C, and C values to reals +using MathObjects. This allows end points like 'pi', 'e', etc. + +=item C<< $data->get_start_point >> + +Gets the starting (left end) point of a function. This should be used when using +function strings to avoid generating the function data. + +=item C<< $data->get_end_point >> + +Gets the ending (right end) point of a function. This should be used when using +function strings to avoid generating the function data. + =back =cut @@ -142,32 +185,47 @@ sub style { } sub get_math_object { - my ($self, $formula, $var) = @_; + my ($self, $formula, $xvar, $yvar) = @_; return $formula if ref($formula) eq 'CODE' || Value::isFormula($formula); my $localContext = Parser::Context->current(\%main::context)->copy; - $localContext->variables->are($var => 'Real') unless $localContext->variables->get($var); + $localContext->variables->are($yvar ? ($xvar => 'Real', $yvar => 'Real') : ($xvar => 'Real')); $formula = Value->Package('Formula')->new($localContext, $formula); return $formula; } sub set_function { my ($self, %options) = @_; - my $f = { Fx => 't', Fy => '', var => 't', min => -5, max => 5 }; - for my $key ('Fx', 'Fy', 'var', 'min', 'max') { + my $f = { + Fx => 't', + Fy => '', + xvar => 't', + yvar => '', + xmin => -5, + xmax => 5, + ymin => -5, + ymax => 5, + xsteps => 30, + ysteps => 15, + }; + for my $key ('Fx', 'Fy', 'xvar', 'yvar', 'xmin', 'xmax', 'ymin', 'ymax', 'xsteps', 'ysteps') { next unless defined $options{$key}; $f->{$key} = $options{$key}; delete $options{$key}; } + for my $key ('var', 'min', 'max', 'steps') { + next unless defined $options{$key}; + $f->{"x$key"} = $options{$key}; + delete $options{$key}; + } return unless $f->{Fy}; - $f->{Fx} = $self->get_math_object($f->{Fx}, $f->{var}); - $f->{Fy} = $self->get_math_object($f->{Fy}, $f->{var}); + $f->{Fx} = $self->get_math_object($f->{Fx}, $f->{xvar}, $f->{yvar}); + $f->{Fy} = $self->get_math_object($f->{Fy}, $f->{xvar}, $f->{yvar}); $self->{function} = $f; $self->style(%options) if %options; return; } -# Using MathObjects allows string values like 2pi/3, e^2, sqrt(2), etc. sub str_to_real { my ($self, $val) = @_; return $val if !$val || $val !~ /[^\d\-\.]/; @@ -178,21 +236,22 @@ sub str_to_real { sub update_min_max { my $self = shift; my $f = $self->{function}; - $f->{min} = $self->str_to_real($f->{min}); - $f->{max} = $self->str_to_real($f->{max}); + $f->{xmin} = $self->str_to_real($f->{xmin}); + $f->{xmax} = $self->str_to_real($f->{xmax}); + $f->{ymin} = $self->str_to_real($f->{ymin}); + $f->{ymax} = $self->str_to_real($f->{ymax}); return; } -# Takes a MathObject function string and replaces with JavaScript functions. -# Function takes either 'x' or 'y' for the corresponding coordinate function. -sub func_to_js { - my ($self, $coord) = @_; +sub function_string { + my ($self, $coord, $type, $nvars) = @_; my $f = $self->{function}; my $MO = $coord eq 'x' ? $f->{Fx} : $coord eq 'y' ? $f->{Fy} : ''; unless ($MO) { warn "Invalid coordinate: $coord"; return ''; } + return '' if ref($MO) eq 'CODE'; # Ensure -x^2 gets print as -(x^2), since JavaScript finds this ambiguous. my $extraParens = $MO->context->flag('showExtraParens'); @@ -201,49 +260,91 @@ sub func_to_js { $func =~ s/\s//g; $MO->context->flags->set(showExtraParens => $extraParens); - my $var = $f->{var}; - my %tokens = ( - sqrt => 'Math.sqrt', - cbrt => 'Math.cbrt', - hypot => 'Math.hypot', - norm => 'Math.hypot', - pow => 'Math.pow', - exp => 'Math.exp', - abs => 'Math.abs', - round => 'Math.round', - floor => 'Math.floor', - ceil => 'Math.ceil', - sign => 'Math.sign', - int => 'Math.trunc', - log => 'Math.ln', - ln => 'Math.ln', - cos => 'Math.cos', - sin => 'Math.sin', - tan => 'Math.tan', - acos => 'Math.acos', - arccos => 'Math.acos', - asin => 'Math.asin', - arcsin => 'Math.asin', - atan => 'Math.atan', - arctan => 'Math.atan', - atan2 => 'Math.atan2', - cosh => 'Math.cosh', - sinh => 'Math.sinh', - tanh => 'Math.tanh', - acosh => 'Math.acosh', - arccosh => 'Math.arccosh', - asinh => 'Math.asinh', - arcsinh => 'Math.asinh', - atanh => 'Math.atanh', - arctanh => 'Math.arctanh', - min => 'Math.min', - max => 'Math.max', - random => 'Math.random', - e => 'Math.E', - pi => 'Math.PI', - '^' => '**', - $var => $var - ); + $nvars = 1 unless $nvars; + my %tokens; + if ($type eq 'PGF') { + my %vars = ($nvars == 2 ? ($f->{xvar} => 'x', $f->{yvar} => 'y') : ($f->{xvar} => 'x')); + %tokens = ( + sqrt => 'sqrt', + pow => 'pow', + exp => 'e^', + abs => 'abs', + round => 'round', + floor => 'floor', + ceil => 'ceil', + sign => 'sign', + int => 'int', + log => 'ln', + ln => 'ln', + cos => 'cos', + sin => 'sin', + tan => 'tan', + sec => 'sec', + csc => 'csc', + cot => 'cot', + acos => 'acos', + arccos => 'acos', + asin => 'asin', + arcsin => 'asin', + atan => 'atan', + arctan => 'atan', + atan2 => 'atan2', + cosh => 'cosh', + sinh => 'sinh', + tanh => 'tanh', + min => 'min', + max => 'max', + random => 'rnd', + e => 'e', + pi => 'pi', + '^' => '^', + %vars + ); + } else { + my %vars = ($nvars == 2 ? ($f->{xvar} => 'x', $f->{yvar} => 'y') : ($f->{xvar} => 't')); + %tokens = ( + sqrt => 'Math.sqrt', + cbrt => 'Math.cbrt', + hypot => 'Math.hypot', + norm => 'Math.hypot', + pow => 'Math.pow', + exp => 'Math.exp', + abs => 'Math.abs', + round => 'Math.round', + floor => 'Math.floor', + ceil => 'Math.ceil', + sign => 'Math.sign', + int => 'Math.trunc', + log => 'Math.ln', + ln => 'Math.ln', + cos => 'Math.cos', + sin => 'Math.sin', + tan => 'Math.tan', + acos => 'Math.acos', + arccos => 'Math.acos', + asin => 'Math.asin', + arcsin => 'Math.asin', + atan => 'Math.atan', + arctan => 'Math.atan', + atan2 => 'Math.atan2', + cosh => 'Math.cosh', + sinh => 'Math.sinh', + tanh => 'Math.tanh', + acosh => 'Math.acosh', + arccosh => 'Math.arccosh', + asinh => 'Math.asinh', + arcsinh => 'Math.asinh', + atanh => 'Math.atanh', + arctanh => 'Math.arctanh', + min => 'Math.min', + max => 'Math.max', + random => 'Math.random', + e => 'Math.E', + pi => 'Math.PI', + '^' => '**', + %vars + ); + } my $out = ''; my $match; @@ -253,26 +354,26 @@ sub func_to_js { if ($tokens{$match}) { $out .= $tokens{$match}; } else { - warn "Unknown token $match in function."; + warn "Unsupported token $match in function. Generating points manually."; return ''; } } elsif (($match) = ($func =~ m/^([^A-Za-z^]+)/)) { $func = substr($func, length($match)); $out .= $match; } else { # Shouldn't happen, but to stop an infinite loop for safety. - warn 'Unknown error parsing function.'; - last; + warn 'Unknown error parsing function. Generating points manually.'; + return ''; } } - return "function($var){ return $out; }"; + return $out; } sub stepsize { - my ($self, $steps) = @_; + my ($self, $steps, $var) = @_; my $f = $self->{function}; $self->update_min_max; - return ($f->{max} - $f->{min}) / $steps; + return ($f->{"${var}max"} - $f->{"${var}min"}) / $steps; } sub get_generator_sub { @@ -281,10 +382,10 @@ sub get_generator_sub { return $f->{"sub_$coord"} if $f->{"sub_$coord"}; my $MO = $f->{"F$coord"}; return $MO if ref($MO) eq 'CODE'; - if ($MO->string eq $f->{var}) { + if ($MO->string eq $f->{xvar}) { $f->{"sub_$coord"} = sub { return $_[0]; } } else { - my $sub = $MO->perlFunction(undef, [ $f->{var} ]); + my $sub = $MO->perlFunction(undef, [ $f->{xvar} ]); $f->{"sub_$coord"} = sub { my $x = shift; my $y = Parser::Eval($sub, $x); @@ -298,9 +399,9 @@ sub gen_data { my $self = shift; my $f = $self->{function}; return if !$f || $self->size; # Only generate the data once. - my $steps = $self->style('steps') || 30; - my $dt = $self->stepsize($steps); - my $t = $f->{min}; + my $steps = $f->{xsteps}; + my $dt = $self->stepsize($steps, 'x'); + my $t = $f->{xmin}; my $sub_x = $self->get_generator_sub('x'); my $sub_y = $self->get_generator_sub('y'); @@ -317,7 +418,7 @@ sub get_start_point { my $f = $self->{function}; my $sub_x = $self->get_generator_sub('x'); my $sub_y = $self->get_generator_sub('y'); - return (&{$sub_x}($f->{min}), &{$sub_y}($f->{min})); + return (&{$sub_x}($f->{xmin}), &{$sub_y}($f->{xmin})); } sub get_end_point { @@ -326,7 +427,7 @@ sub get_end_point { my $f = $self->{function}; my $sub_x = $self->get_generator_sub('x'); my $sub_y = $self->get_generator_sub('y'); - return (&{$sub_x}($f->{max}), &{$sub_y}($f->{max})); + return (&{$sub_x}($f->{xmax}), &{$sub_y}($f->{xmax})); } sub _add { diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index 8c4d596e08..b0b0ae5b6d 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -90,18 +90,28 @@ sub add_curve { my $type = 'curve'; my $data_points; - if ($data->name eq 'function' && ref($data->{function}{Fx}) ne 'CODE' && ref($data->{function}{Fy}) ne 'CODE') { + if ($data->name eq 'function') { my $f = $data->{function}; - $data->update_min_max; - if ($f->{var} eq $f->{Fx}->string) { - my $min = $data->style('continue_left') ? '' : $f->{min}; - my $max = $data->style('continue_right') ? '' : $f->{max}; - $type = 'functiongraph'; - $data_points = '[' . $data->func_to_js('y') . ", $min, $max]"; + if (ref($f->{Fx}) ne 'CODE' && $f->{xvar} eq $f->{Fx}->string) { + my $function = $data->function_string('y', 'js', 1); + if ($function) { + my $min = $data->style('continue_left') ? '' : $f->{xmin}; + my $max = $data->style('continue_right') ? '' : $f->{xmax}; + $data->update_min_max; + $type = 'functiongraph'; + $data_points = "[function(t){ return $function; }, $min, $max]"; + } } else { - $data_points = '[' . $data->func_to_js('x') . ', ' . $data->func_to_js('y') . ", $f->{min}, $f->{max}]"; + my $xfunction = $data->function_string('x', 'js', 1); + my $yfunction = $data->function_string('y', 'js', 1); + if ($xfunction && $yfunction) { + $data->update_min_max; + $data_points = "[function(t){ return $xfunction; }, function(t){ return $yfunction; }, " + . "$f->{xmin}, $f->{xmax}]"; + } } - } else { + } + unless ($data_points) { $data->gen_data; $data_points = '[[' . join(',', $data->x) . '],[' . join(',', $data->y) . ']]'; } @@ -345,6 +355,36 @@ sub draw { $self->add_points($data); } + # Vector/Slope Fields + for my $data ($plots->data('vectorfield')) { + my $xfunction = $data->function_string('x', 'js', 2); + my $yfunction = $data->function_string('y', 'js', 2); + + if ($xfunction && $yfunction) { + my $f = $data->{function}; + my $JSXGraphOpts = $data->style('JSXGraphOpts') || {}; + my $options = Mojo::JSON::encode_json({ + strokeColor => $self->get_color($data->style('color')), + strokeWidth => $data->style('width'), + scale => $data->style('scale') || 1, + ($data->style('slopefield') ? (arrowhead => { enabled => 0 }) : ()), + %$JSXGraphOpts, + }); + $data->update_min_max; + + if ($data->style('normalize') || $data->style('slopefield')) { + my $xtmp = "($xfunction)/Math.sqrt(($xfunction)**2 + ($yfunction)**2)"; + $yfunction = "($yfunction)/Math.sqrt(($xfunction)**2 + ($yfunction)**2)"; + $xfunction = $xtmp; + } + + $self->{JS} .= "\n\t\tboard_$name.create('vectorfield', [[(x,y) => $xfunction, (x,y) => $yfunction], " + . "[$f->{xmin}, $f->{xsteps}, $f->{xmax}], [$f->{ymin}, $f->{ysteps}, $f->{ymax}]], $options);"; + } else { + warn "Vector field not created due to missing JavaScript functions."; + } + } + # Stamps for my $stamp ($plots->data('stamp')) { my $mark = $stamp->style('symbol'); diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm index 15a1259d80..3fbb095cc3 100644 --- a/lib/Plots/Plot.pm +++ b/lib/Plots/Plot.pm @@ -191,9 +191,9 @@ sub _add_function { $data->set_function( Fx => $Fx, Fy => $Fy, - var => $var, - min => $min, - max => $max, + xvar => $var, + xmin => $min, + xmax => $max, color => 'default_color', width => 2, dashed => 0, @@ -289,6 +289,29 @@ sub add_dataset { return $self->_add_dataset(@data); } +sub add_vectorfield { + my ($self, @options) = @_; + my $data = Plots::Data->new(name => 'vectorfield'); + $data->set_function( + Fx => '', + Fy => '', + xvar => 'x', + yvar => 'y', + xmin => -5, + xmax => 5, + ymin => -5, + ymax => 5, + xsteps => 15, + ysteps => 15, + width => 1, + color => 'default_color', + @options + ); + + $self->add_data($data); + return $data; +} + sub _add_label { my ($self, $x, $y, @options) = @_; my $data = Plots::Data->new(name => 'label'); diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 850e387fdc..d96635e9cc 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -116,6 +116,8 @@ sub configure_axes { my $tikzCode = <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"; + $tikzCode .= $self->get_color($color); + my $plot; + if ($data->name eq 'function') { + my $f = $data->{function}; + if (ref($f->{Fx}) ne 'CODE' && $f->{xvar} eq $f->{Fx}->string) { + my $function = $data->function_string('y', 'PGF', 1); + if ($function) { + $data->update_min_max; + $tikzOpts .= ", domain=$f->{xmin}:$f->{xmax}, samples=$f->{xsteps}"; + $plot = "{$function}"; + } + } else { + my $xfunction = $data->function_string('x', 'PGF', 1); + my $yfunction = $data->function_string('y', 'PGF', 1); + if ($xfunction && $yfunction) { + $data->update_min_max; + $tikzOpts .= ", domain=$f->{xmin}:$f->{xmax}, samples=$f->{xsteps}"; + $plot = "({$xfunction}, {$yfunction})"; + } + } + } + unless ($plot) { + $data->gen_data; + my $tikzData = join(' ', map { '(' . $data->x($_) . ',' . $data->y($_) . ')'; } (0 .. $n - 1)); + $plot = "coordinates {$tikzData}"; + } + $tikzCode .= "\\addplot[$tikzOpts] $plot;\n"; + $tikzCode .= $self->get_color($fill_color) unless $fill eq 'none'; unless ($fill eq 'none' || $fill eq 'self') { my $name = $data->style('name') || ''; my $opacity = $data->style('fill_opacity') || 0.5; @@ -224,6 +250,36 @@ sub draw { } } + # Vector/Slope Fields + for my $data ($plots->data('vectorfield')) { + my $xfunction = $data->function_string('x', 'PGF', 2); + my $yfunction = $data->function_string('y', 'PGF', 2); + my $arrows = $data->style('slopefield') ? '' : ', -stealth'; + if ($xfunction && $yfunction) { + my $f = $data->{function}; + my $color = $data->style('color'); + my $width = $data->style('width'); + my $scale = $data->style('scale'); + my $tikzOpts = $data->style('tikzOpts') || ''; + $tikzOpts = ", $tikzOpts" if $tikzOpts; + $data->update_min_max; + + if ($data->style('normalize') || $data->style('slopefield')) { + my $xtmp = "($xfunction)/sqrt(($xfunction)^2 + ($yfunction)^2)"; + $yfunction = "($yfunction)/sqrt(($xfunction)^2 + ($yfunction)^2)"; + $xfunction = $xtmp; + } + + $tikzCode .= $self->get_color($color); + $tikzCode .= + "\\addplot3[color=$color, line width=${width}pt$arrows, " + . "quiver={u=$xfunction, v=$yfunction, scale arrows=$scale}, samples=$f->{xsteps}, " + . "domain=$f->{xmin}:$f->{xmax}, domain y=$f->{ymin}:$f->{ymax}$tikzOpts] {1};\n"; + } else { + warn "Vector field not created due to missing JavaScript functions."; + } + } + # Stamps for my $stamp ($plots->data('stamp')) { my $mark = $self->get_mark($stamp->style('symbol')); diff --git a/macros/graph/VectorField2D.pl b/macros/graph/VectorField2D.pl index 369e749ae1..2c50b48d44 100644 --- a/macros/graph/VectorField2D.pl +++ b/macros/graph/VectorField2D.pl @@ -116,6 +116,23 @@ sub VectorField2D { my $Fx = $options{Fx}; my $Fy = $options{Fy}; + + return $gr->add_vectorfield( + Fx => $Fx, + Fy => $Fy, + xvar => $options{xvar}, + yvar => $options{yvar}, + xmin => $options{xmin}, + xmax => $options{xmax}, + ymin => $options{ymin}, + ymax => $options{ymax}, + xsteps => $options{xsamples}, + ysteps => $options{ysamples}, + width => $options{vectorthickness}, + color => $options{vectorcolor}, + scale => $options{vectorscale}, + ) if ref($gr) eq 'Plots::Plot'; + if (Value::isFormula($Fx)) { $Fx = $Fx->perlFunction('', [ "$options{xvar}", "$options{yvar}" ]); } elsif (ref($Fx) ne 'CODE') { @@ -129,9 +146,6 @@ sub VectorField2D { return; } - # Takes to long to render this field using Tikz, force GD output. - $gr->image_type('GD') if (ref($gr) eq 'Plots::Plot'); - # Generate plot data my $dx = ($options{xmax} - $options{xmin}) / $options{xsamples}; my $dy = ($options{ymax} - $options{ymin}) / $options{ysamples}; diff --git a/macros/graph/plots.pl b/macros/graph/plots.pl index 8012d061b1..72e537161f 100644 --- a/macros/graph/plots.pl +++ b/macros/graph/plots.pl @@ -152,8 +152,69 @@ =head2 PLOT FUNCTIONS width => 2 ); -It is prefered to use strings or MathObjects instead of perl subroutiens when -using the default C output. +It is preferred to use strings or MathObjects instead of perl subroutines. + +=head2 PLOT VECTOR FIELDS + +Vector fields and slope fields can be plotted using the C<< $plot->add_vectorfield >> method. + + $plot->add_vectorfield( + Fx => 'sin(y)', + Fy => 'cos(x)', + xvar => 'x', + yvar => 'y', + xmin => -4, + xmax => 4, + ymin => -4, + ymax => 4, + xsteps => 20, + ysteps => 15, + color => 'blue', + scale => 0.5, + ); + +This only works if C and C are strings or MathObjects (no perl functions allowed), +because the functions are passed off to either JSXGraph or TikZ to do the computation. +To make all the vectors the same length, add the C<< normalize => 1 >> option. To plot a slope +field add C<< slopefield => 1 >> option (which removes the arrow heads and makes all the +lines the same length), then set C<< Fx => 1 >> and C equal to the formula to produce +the slope field. + +In addition to the dataset options below, the following additional options apply to +vector fields. + +=over 5 + +=item xvar, yvar + +Name of the x-axis and y-axis variables used. Default: x and y + +=item xmin, xmax, ymin, ymax + +Range of the x and y coordinates of the vector field. Default: -5 to 5 + +=item xsteps, ysteps + +The number of arrows drawn in each direction. Note, that in TikZ output, this cannot be +set individually so only C is used. Default: 15 + +=item scale + +A scale factor applied to the arrow length. Default: 1 + +=item normalize + +Makes all the arrows the same length. This just turns C and C into +C<(Fx)/sqrt((Fx)^2 + (Fy)^2)> and C<(Fy)/sqrt((Fx)^2 + (Fy)^2)> for convince. +Default: 0 + +=item slopefield + +This removes the arrow heads and implies normalized (so all the lines are the same length). +Use this in combination with setting C 1> and C equal to the slope field formula +to graph a slope field instead of a vector field. Default: 0 + +=back =head2 DATASET OPTIONS From 300305e722bc48d964e27a09445586f2bdd630e2 Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Mon, 9 Jun 2025 10:14:28 -0600 Subject: [PATCH 08/19] Various improvements to plotting. * Save a single copy of the current context in the Plots::Plot object, then pass it to all functions to use to create their math objects. This avoids modifying the actual context and a single copy is used vs each function needing to create their own copy. * Since f(x) = 0 is a valid function, function string tests need to explicitly test that the string is not '', vs just testing it is true. * Disable highlighting of elements on mouse over by default. * Use JSX.merge to merge user options jsx_options vs having them have to supply all options for sub hashes. * Name the options for TikZ and JSXGraph, tikz_options and jsx_options to match style of other options. * Remove the title from the graph, the title option now only sets the ARIA label. * Add a CSS file htdocs/js/Plots/plots.scss to control the style of the JSXGraph box. * Add an option jsx_navigation that turns on zooming and panning, and also shows the navigation buttons. Disabled by default. * Update the $plots->axes->set and $plots->axes->get methods to send anything that doesn't start with an x or y to the $plots->axes->style. This allows configuring the axes in a single call. In addition pass all options sent to the Plots() method to $plots->axes->set, so the axes can be configured with the initial call to Plots(). * POD updates. * Fix calls to `$main` by wrapping them in an eval statement. --- htdocs/js/Plots/plots.scss | 6 ++ lib/Plots/Axes.pm | 49 +++++++------ lib/Plots/Data.pm | 24 ++++--- lib/Plots/JSXGraph.pm | 141 +++++++++++++++++++------------------ lib/Plots/Plot.pm | 51 +++++++++----- lib/Plots/Tikz.pm | 80 ++++++++++----------- macros/graph/plots.pl | 77 +++++++++++--------- 7 files changed, 235 insertions(+), 193 deletions(-) create mode 100644 htdocs/js/Plots/plots.scss diff --git a/htdocs/js/Plots/plots.scss b/htdocs/js/Plots/plots.scss new file mode 100644 index 0000000000..a7258fceca --- /dev/null +++ b/htdocs/js/Plots/plots.scss @@ -0,0 +1,6 @@ +.plots-jsxgraph { + display: inline-block; + margin: 1rem; + border-radius: 0px; + -webkit-border-radius: 0px; +} diff --git a/lib/Plots/Axes.pm b/lib/Plots/Axes.pm index a5fc1bd2bf..97815b23af 100644 --- a/lib/Plots/Axes.pm +++ b/lib/Plots/Axes.pm @@ -27,12 +27,15 @@ The axes object should be accessed through a Plots object using C<< $plot->axes The axes object is used to configure and retrieve information about the axes, as in the following examples. -Each axis can be configured individually, such as: +Each axis and styles can be configured individually, such as: $plot->axes->xaxis(min => -10, max => 10, tick_delta => 4); $plot->axes->yaxis(min => 0, max => 100, tick_delta => 20); + $plot->axes->style(title => 'Graph of function y = f(x).', show_grid => 0); -This can also be combined using the set method, such as: +This can be combined using the set method by prepending either C or C in front +of each key of the axes to configure (note keys that do not start with C or C +sent to C<< $plot->axes->style >>): $plot->axes->set( xmin => -10, @@ -40,19 +43,16 @@ This can also be combined using the set method, such as: xtick_delta => 4, ymin => 0, ymax => 100, - ytick_delta => 20 + ytick_delta => 20, + title => 'Graph of function y = f(x).', + show_grid => 0, ); -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'); + $xmin = $plot->axes->xaxis('min'); + $yticks = $plot->axes->yaxis('ticks'); + $show_grid = $plot->axes->style('show_grid'); The methods without any inputs return a reference to the full hash, such as: @@ -149,7 +149,7 @@ Default 'middle' or 'center'. The position in terms of the appropriate variable to draw the axis if the location is set to 'middle' or 'center'. Default is 0. -=item JSXGraphOpts +=item jsx_options A hash reference of options to be passed to the JSXGraph axis objects. @@ -157,16 +157,13 @@ A hash reference of options to be passed to the JSXGraph axis objects. =head1 STYLES -The following styles configure aspects about the axes: -Currently only TikZ supports the grid color, style, alpha, -and axes on top styles. JSXGraph only supports the title -and show_grid styles. +The following styles configure aspects about the axes. =over 5 =item title -The title of the graph. Default is ''. +The title of the graph used as the ARIA label in JSX graph output. Default is ''. =item show_grid @@ -192,7 +189,12 @@ Configures if the axis should be drawn on top of the graph (1) or below the grap 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 -=item JSXGraphOpts +=item jsx_navigation + +Either allow (1) or don't allow (0) the user to pan and zoom the view port of the JSXGraph. +Best used when plotting functions with the C style. Default: 0 + +=item jsx_options A hash reference of options to be passed to the JSXGraph board object. @@ -273,16 +275,19 @@ sub yaxis { sub set { my ($self, %options) = @_; - my (%xopts, %yopts); + my (%xopts, %yopts, %styles); for (keys %options) { if ($_ =~ s/^x//) { $xopts{$_} = $options{"x$_"}; } elsif ($_ =~ s/^y//) { $yopts{$_} = $options{"y$_"}; + } else { + $styles{$_} = $options{$_}; } } - $self->xaxis(%xopts) if %xopts; - $self->yaxis(%yopts) if %yopts; + $self->xaxis(%xopts) if %xopts; + $self->yaxis(%yopts) if %yopts; + $self->style(%styles) if %styles; return; } @@ -294,6 +299,8 @@ sub get { $options{"x$_"} = $self->xaxis($_); } elsif ($_ =~ s/^y//) { $options{"y$_"} = $self->yaxis($_); + } else { + $options{$_} = $self->style($_); } } return \%options; diff --git a/lib/Plots/Data.pm b/lib/Plots/Data.pm index d20b243860..e2a372becb 100644 --- a/lib/Plots/Data.pm +++ b/lib/Plots/Data.pm @@ -56,6 +56,7 @@ turned into MathObjects), MathObjects, or per subroutines. The core function dat stored in the C<< $data->{function} >> hash, though other data is stored as a style. $data->set_function( + $self->context, Fx => Formula('t'), Fy => Formula('t^2'), var => 't', @@ -64,9 +65,13 @@ stored in the C<< $data->{function} >> hash, though other data is stored as a st steps => 50, ); +Note, the first argument must be $self->context when called from C +to use a single context for all C objects. + This is also used to set a two variable function (used for slope or vector fields): $data->set_function( + $self->context, Fx => Formula('x^2 + y^2'), Fy => Formula('x - y'), xvar => 'x', @@ -187,14 +192,16 @@ sub style { sub get_math_object { my ($self, $formula, $xvar, $yvar) = @_; return $formula if ref($formula) eq 'CODE' || Value::isFormula($formula); - my $localContext = Parser::Context->current(\%main::context)->copy; - $localContext->variables->are($yvar ? ($xvar => 'Real', $yvar => 'Real') : ($xvar => 'Real')); - $formula = Value->Package('Formula')->new($localContext, $formula); + my $context = $self->{context}; + $context->variables->add($xvar => 'Real') unless $context->variables->get($xvar); + $context->variables->add($yvar => 'Real') if $yvar && !$context->variables->get($yvar); + $formula = Value->Package('Formula')->new($context, $formula); return $formula; } sub set_function { - my ($self, %options) = @_; + my ($self, $context, %options) = @_; + $self->{context} = $context; my $f = { Fx => 't', Fy => '', @@ -229,8 +236,7 @@ sub set_function { sub str_to_real { my ($self, $val) = @_; return $val if !$val || $val !~ /[^\d\-\.]/; - my $localContext = Parser::Context->current(\%main::context); - return Value->Package('Real')->new($localContext, $val)->value; + return Value->Package('Real')->new($self->{context}, $val)->value; } sub update_min_max { @@ -246,11 +252,7 @@ sub update_min_max { sub function_string { my ($self, $coord, $type, $nvars) = @_; my $f = $self->{function}; - my $MO = $coord eq 'x' ? $f->{Fx} : $coord eq 'y' ? $f->{Fy} : ''; - unless ($MO) { - warn "Invalid coordinate: $coord"; - return ''; - } + my $MO = $coord eq 'y' ? $f->{Fy} : $f->{Fx}; return '' if ref($MO) eq 'CODE'; # Ensure -x^2 gets print as -(x^2), since JavaScript finds this ambiguous. diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index b0b0ae5b6d..c4a4102157 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -15,8 +15,9 @@ use warnings; sub new { my ($class, $plots) = @_; - $plots->insert_css('node_modules/jsxgraph/distrib/jsxgraph.css'); - $plots->insert_js('node_modules/jsxgraph/distrib/jsxgraphcore.js'); + $plots->add_css_file('node_modules/jsxgraph/distrib/jsxgraph.css'); + $plots->add_css_file('js/Plots/plots.css'); + $plots->add_js_file('node_modules/jsxgraph/distrib/jsxgraphcore.js'); return bless { plots => $plots }, $class; } @@ -28,16 +29,19 @@ sub plots { sub HTML { my $self = shift; + my $name = $self->{name}; + my ($width, $height) = $self->plots->size; + return <{board} +
END_HTML @@ -77,16 +81,17 @@ sub add_curve { my $fill = $data->style('fill') || 'none'; my $fill_color = $self->get_color($data->style('fill_color') || 'default_color'); my $fill_opacity = $data->style('fill_opacity') || 0.5; - my $JSXGraphOpts = $data->style('JSXGraphOpts') || {}; my $plotOptions = Mojo::JSON::encode_json({ + highlight => 0, strokeColor => $color, strokeWidth => $line_width, $start eq 'arrow' ? (firstArrow => { type => 5, size => $arrow_size }) : (), $end eq 'arrow' ? (lastArrow => { type => 5, size => $arrow_size }) : (), $fill eq 'self' ? (fillColor => $fill_color, fillOpacity => $fill_opacity) : (), %linestyles, - %$JSXGraphOpts, }); + $plotOptions = "JXG.merge($plotOptions, " . Mojo::JSON::encode_json($data->style('jsx_options')) . ')' + if $data->style('jsx_options'); my $type = 'curve'; my $data_points; @@ -94,20 +99,19 @@ sub add_curve { my $f = $data->{function}; if (ref($f->{Fx}) ne 'CODE' && $f->{xvar} eq $f->{Fx}->string) { my $function = $data->function_string('y', 'js', 1); - if ($function) { - my $min = $data->style('continue_left') ? '' : $f->{xmin}; - my $max = $data->style('continue_right') ? '' : $f->{xmax}; + if ($function ne '') { + my $min = $data->style('continue') || $data->style('continue_left') ? '' : $f->{xmin}; + my $max = $data->style('continue') || $data->style('continue_right') ? '' : $f->{xmax}; $data->update_min_max; $type = 'functiongraph'; - $data_points = "[function(t){ return $function; }, $min, $max]"; + $data_points = "[t => $function, $min, $max]"; } } else { my $xfunction = $data->function_string('x', 'js', 1); my $yfunction = $data->function_string('y', 'js', 1); - if ($xfunction && $yfunction) { + if ($xfunction ne '' && $yfunction ne '') { $data->update_min_max; - $data_points = "[function(t){ return $xfunction; }, function(t){ return $yfunction; }, " - . "$f->{xmin}, $f->{xmax}]"; + $data_points = "[t => $xfunction, t => $yfunction, $f->{xmin}, $f->{xmax}]"; } } } @@ -131,6 +135,7 @@ sub add_curve { strokeWidth => 0, fillColor => $fill_color, fillOpacity => $fill_opacity, + highlight => 0, }); if ($fill eq 'xaxis') { @@ -220,7 +225,6 @@ sub add_point { return; } - my $JSXGraphOpts = $data->style('JSXGraphOpts') || {}; my $pointOptions = Mojo::JSON::encode_json({ fixed => 1, withLabel => 0, @@ -228,8 +232,11 @@ sub add_point { strokeColor => $color, fillColor => $fill, size => $size, - %$JSXGraphOpts + highlight => 0, + showInfoBox => 0, }); + $pointOptions = "JXG.merge($pointOptions, " . Mojo::JSON::encode_json($data->style('jsx_options')) . ')' + if $data->style('jsx_options'); $self->{JS} .= "\n\t\tboard_$name.create('point', [$x, $y], $pointOptions);"; } @@ -248,23 +255,17 @@ sub add_points { } sub init_graph { - my $self = shift; - my $plots = $self->plots; - my $axes = $plots->axes; - my $grid = $axes->grid; - my $name = $self->{name}; - my $title = $axes->style('title'); - my $xaxis_loc = $axes->xaxis('location'); - my $yaxis_loc = $axes->yaxis('location'); - my $xaxis_pos = $axes->xaxis('position'); - my $yaxis_pos = $axes->yaxis('position'); - my $show_grid = $axes->style('show_grid'); + my $self = shift; + my $plots = $self->plots; + my $axes = $plots->axes; + my $name = $self->{name}; + my $xaxis_loc = $axes->xaxis('location'); + my $yaxis_loc = $axes->yaxis('location'); + my $xaxis_pos = $axes->xaxis('position'); + my $yaxis_pos = $axes->yaxis('position'); + my $show_grid = $axes->style('show_grid'); + my $allow_navigation = $axes->style('jsx_navigation') ? 1 : 0; my ($xmin, $ymin, $xmax, $ymax) = $axes->bounds; - my ($width, $height) = $plots->size; - my $style = 'display: inline-block; margin: 5px; text-align: center;'; - my $JSXGraphAxesOpts = $axes->style('JSXGraphOpts') || {}; - my $JSXGraphXAxisOpts = $axes->xaxis('JSXGraphOpts') || {}; - my $JSXGraphYAxisOpts = $axes->yaxis('JSXGraphOpts') || {}; # Adjust bounding box to add padding for axes at edge of graph. $xaxis_loc = 'bottom' if $xaxis_loc eq 'box'; @@ -274,31 +275,29 @@ sub init_graph { $ymin -= 0.11 * ($ymax - $ymin) if $xaxis_loc eq 'bottom' || $ymin == $yaxis_pos; $ymax += 0.11 * ($ymax - $ymin) if $xaxis_loc eq 'top' || $ymax == $yaxis_pos; - $title = "$title" if $title; - $self->{board} = <$title -
- -END_HTML - my $JSXOptions = Mojo::JSON::encode_json({ - title => $title || 'Graph', - description => $plots->{ariaDescription}, + title => $axes->style('title') || 'Graph', + description => $axes->style('ariaDescription'), boundingBox => [ $xmin, $ymax, $xmax, $ymin ], axis => 0, - showNavigation => 0, + showNavigation => $allow_navigation, + pan => { enabled => $allow_navigation }, + zoom => { enabled => $allow_navigation }, showCopyright => 0, - %$JSXGraphAxesOpts }); + $JSXOptions = "JXG.merge($JSXOptions, " . Mojo::JSON::encode_json($axes->style('jsx_options')) . ')' + if $axes->style('jsx_options'); my $XAxisOptions = Mojo::JSON::encode_json({ name => $axes->xaxis('label'), withLabel => 1, position => $xaxis_loc eq 'middle' ? 'sticky' : 'fixed', anchor => $xaxis_loc eq 'top' ? 'left' : $xaxis_loc eq 'bottom' ? 'right' : 'right left', visible => $axes->xaxis('visible') ? 1 : 0, + highlight => 0, label => { - position => 'rt', - offset => [ -10, 10 ] + position => 'rt', + offset => [ -10, 10 ], + highlight => 0 }, ticks => { drawLabels => $axes->xaxis('tick_labels') && $axes->xaxis('show_ticks') ? 1 : 0, @@ -308,18 +307,22 @@ END_HTML majorHeight => $axes->xaxis('show_ticks') ? ($show_grid && $axes->xaxis('major') ? -1 : 10) : 0, minorTicks => $axes->xaxis('major') ? $axes->xaxis('minor') : 0, minorHeight => $axes->xaxis('show_ticks') ? ($show_grid ? -1 : 7) : 0, + label => { highlight => 0 }, }, - %$JSXGraphXAxisOpts }); + $XAxisOptions = "JXG.merge($XAxisOptions, " . Mojo::JSON::encode_json($axes->xaxis('jsx_options')) . ')' + if $axes->xaxis('jsx_options'); my $YAxisOptions = Mojo::JSON::encode_json({ name => $axes->yaxis('label'), withLabel => 1, position => $yaxis_loc eq 'center' ? 'sticky' : 'fixed', anchor => $yaxis_loc eq 'center' ? 'right left' : $yaxis_loc, visible => $axes->yaxis('visible') ? 1 : 0, + highlight => 0, label => { - position => 'rt', - offset => [ 10, -10 ] + position => 'rt', + offset => [ 10, -10 ], + highlight => 0, }, ticks => { drawLabels => $axes->yaxis('tick_labels') && $axes->yaxis('show_ticks') ? 1 : 0, @@ -329,9 +332,11 @@ END_HTML majorHeight => $axes->yaxis('show_ticks') ? ($show_grid && $axes->yaxis('major') ? -1 : 10) : 0, minorTicks => $axes->yaxis('major') ? $axes->yaxis('minor') : 0, minorHeight => $axes->yaxis('show_ticks') ? ($show_grid ? -1 : 7) : 0, + label => { highlight => 0 }, }, - %$JSXGraphYAxisOpts }); + $YAxisOptions = "JXG.merge($YAxisOptions, " . Mojo::JSON::encode_json($axes->yaxis('jsx_options')) . ')' + if $axes->yaxis('jsx_options'); $self->{JSend} = ''; $self->{JS} = <function_string('x', 'js', 2); my $yfunction = $data->function_string('y', 'js', 2); - if ($xfunction && $yfunction) { - my $f = $data->{function}; - my $JSXGraphOpts = $data->style('JSXGraphOpts') || {}; - my $options = Mojo::JSON::encode_json({ + if ($xfunction ne '' && $yfunction ne '') { + my $f = $data->{function}; + my $options = Mojo::JSON::encode_json({ + highlight => 0, strokeColor => $self->get_color($data->style('color')), strokeWidth => $data->style('width'), scale => $data->style('scale') || 1, ($data->style('slopefield') ? (arrowhead => { enabled => 0 }) : ()), - %$JSXGraphOpts, }); $data->update_min_max; + $options = "JXG.merge($options, " . Mojo::JSON::encode_json($data->style('jsx_options')) . ')' + if $data->style('jsx_option'); if ($data->style('normalize') || $data->style('slopefield')) { my $xtmp = "($xfunction)/Math.sqrt(($xfunction)**2 + ($yfunction)**2)"; @@ -400,25 +406,26 @@ sub draw { # Labels for my $label ($plots->data('label')) { - my $str = $label->style('label'); - my $x = $label->x(0); - my $y = $label->y(0); - my $color = $self->get_color($label->style('color') || 'default_color'); - my $fontsize = $label->style('fontsize') || 'medium'; - my $orientation = $label->style('orientation') || 'horizontal'; - 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' : ''; - my $JSXGraphOpts = $label->style('JSXGraphOpts') || {}; - my $textOptions = Mojo::JSON::encode_json({ + my $str = $label->style('label'); + my $x = $label->x(0); + my $y = $label->y(0); + my $color = $self->get_color($label->style('color') || 'default_color'); + my $fontsize = $label->style('fontsize') || 'medium'; + my $orientation = $label->style('orientation') || 'horizontal'; + 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' : ''; + my $textOptions = Mojo::JSON::encode_json({ + highlight => 0, fontSize => { tiny => 8, small => 10, medium => 12, large => 14, giant => 16 }->{$fontsize}, rotate => $orientation eq 'vertical' ? 90 : 0, strokeColor => $color, anchorX => $h_align eq 'center' ? 'middle' : $h_align, anchorY => $v_align, cssStyle => 'padding: 3px;', - %$JSXGraphOpts, }); + $textOptions = "JXG.merge($textOptions, " . Mojo::JSON::encode_json($label->style('jsx_options')) . ')' + if $label->style('jsx_options'); $self->{JS} .= "\n\t\tboard_$name.create('text', [$x, $y, '$str'], $textOptions);"; } diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm index 3fbb095cc3..c93221b361 100644 --- a/lib/Plots/Plot.pm +++ b/lib/Plots/Plot.pm @@ -19,11 +19,10 @@ use Plots::JSXGraph; use Plots::GD; sub new { - my ($class, $pg, %options) = @_; - my $size = $main::envir{onTheFlyImageSize} || 350; + my ($class, %options) = @_; + my $size = eval('$main::envir{onTheFlyImageSize}') || 350; my $self = bless { - pg => $pg, imageName => {}, width => $size, height => $size, @@ -32,30 +31,46 @@ sub new { axes => Plots::Axes->new, colors => {}, data => [], - %options }, $class; + # Besides for these core options, pass everything else to the Axes object. + for ('width', 'height', 'tex_size', 'ariaDescription') { + if ($options{$_}) { + $self->{$_} = $options{$_}; + delete $options{$_}; + } + } + $self->axes->set(%options) if %options; + + $self->{pg} = eval('$main::PG'); $self->color_init; $self->image_type('JSXGraph'); return $self; } -# Only insert js file if it isn't already inserted. -sub insert_js { +sub pgCall { + my ($call, @args) = @_; + WeBWorK::PG::Translator::PG_restricted_eval('\&' . $call)->(@args); + return; +} + +sub add_js_file { 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 } }); + pgCall('ADD_JS_FILE', $file); + return; } -# Only insert css file if it isn't already inserted. -sub insert_css { +sub add_css_file { 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 }); + pgCall('ADD_CSS_FILE', $file); + return; +} + +sub context { + my $self = shift; + return $self->{context} if $self->{context}; + $self->{context} = Parser::Context->current->copy; + return $self->{context}; } sub colors { @@ -153,7 +168,7 @@ sub image_type { } if ($ext) { - if (grep(/^$ext$/, @validExt)) { + if (grep {/^$ext$/} @validExt) { $self->{ext} = $ext; } else { warn "Plots: Invalid image extension $ext."; @@ -189,6 +204,7 @@ sub _add_function { my $data = Plots::Data->new(name => 'function'); $data->set_function( + $self->context, Fx => $Fx, Fy => $Fy, xvar => $var, @@ -293,6 +309,7 @@ sub add_vectorfield { my ($self, @options) = @_; my $data = Plots::Data->new(name => 'vectorfield'); $data->set_function( + $self->context, Fx => '', Fy => '', xvar => 'x', diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index d96635e9cc..18280d99a0 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -16,8 +16,8 @@ sub new { my ($class, $plots) = @_; my $image = LaTeXImage->new; $image->environment('tikzpicture'); - $image->svgMethod($main::envir{latexImageSVGMethod} // 'dvisvgm'); - $image->convertOptions($main::envir{latexImageConvertOptions} // { input => {}, output => {} }); + $image->svgMethod(eval('$main::envir{latexImageSVGMethod}') // 'dvisvgm'); + $image->convertOptions(eval('$main::envir{latexImageConvertOptions}') // { input => {}, output => {} }); $image->ext($plots->ext); $image->tikzLibraries('arrows.meta,plotmarks'); $image->texPackages(['pgfplots']); @@ -91,7 +91,6 @@ sub configure_axes { my $ylabel = $axes->yaxis('label'); my $axis_y_line = $axes->yaxis('location'); my $axis_y_pos = $axes->yaxis('position'); - 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 = ''; @@ -124,7 +123,6 @@ sub configure_axes { axis y line=$axis_y_line$axis_y_pos, xlabel={$xlabel}, ylabel={$ylabel}, - title={$title}, xtick=$xticks,$xtick_labels ytick=$yticks,$ytick_labels xmajorgrids=$xmajor, @@ -161,8 +159,8 @@ sub get_plot_opts { 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') || ''; - my $smooth = $data->style('tikz_smooth') ? 'smooth, ' : ''; + my $tikz_options = $data->style('tikz_options') ? ', ' . $data->style('tikz_options') : ''; + my $smooth = $data->style('tikz_smooth') ? 'smooth, ' : ''; if ($start =~ /circle/) { $start = '{Circle[sep=-1.196825pt -1.595769' . ($start eq 'open_circle' ? ', open' : '') . ']}'; @@ -187,10 +185,9 @@ sub get_plot_opts { } else { $fill = ''; } - $name = ", name path=$name" if $name; - $tikzOpts = ", $tikzOpts" if $tikzOpts; + $name = ", name path=$name" if $name; - return "${smooth}color=$color, line width=${width}pt$marks$linestyle$end_markers$fill$name$tikzOpts"; + return "${smooth}color=$color, line width=${width}pt$marks$linestyle$end_markers$fill$name$tikz_options"; } sub draw { @@ -205,28 +202,28 @@ sub draw { # Plot Data for my $data ($plots->data('function', 'dataset')) { - 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 $tikzOpts = $self->get_plot_opts($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 $tikz_options = $self->get_plot_opts($data); $tikzCode .= $self->get_color($color); my $plot; if ($data->name eq 'function') { my $f = $data->{function}; if (ref($f->{Fx}) ne 'CODE' && $f->{xvar} eq $f->{Fx}->string) { my $function = $data->function_string('y', 'PGF', 1); - if ($function) { + if ($function ne '') { $data->update_min_max; - $tikzOpts .= ", domain=$f->{xmin}:$f->{xmax}, samples=$f->{xsteps}"; + $tikz_options .= ", domain=$f->{xmin}:$f->{xmax}, samples=$f->{xsteps}"; $plot = "{$function}"; } } else { my $xfunction = $data->function_string('x', 'PGF', 1); my $yfunction = $data->function_string('y', 'PGF', 1); - if ($xfunction && $yfunction) { + if ($xfunction ne '' && $yfunction ne '') { $data->update_min_max; - $tikzOpts .= ", domain=$f->{xmin}:$f->{xmax}, samples=$f->{xsteps}"; + $tikz_options .= ", domain=$f->{xmin}:$f->{xmax}, samples=$f->{xsteps}"; $plot = "({$xfunction}, {$yfunction})"; } } @@ -236,7 +233,7 @@ sub draw { my $tikzData = join(' ', map { '(' . $data->x($_) . ',' . $data->y($_) . ')'; } (0 .. $n - 1)); $plot = "coordinates {$tikzData}"; } - $tikzCode .= "\\addplot[$tikzOpts] $plot;\n"; + $tikzCode .= "\\addplot[$tikz_options] $plot;\n"; $tikzCode .= $self->get_color($fill_color) unless $fill eq 'none'; unless ($fill eq 'none' || $fill eq 'self') { @@ -255,13 +252,12 @@ sub draw { my $xfunction = $data->function_string('x', 'PGF', 2); my $yfunction = $data->function_string('y', 'PGF', 2); my $arrows = $data->style('slopefield') ? '' : ', -stealth'; - if ($xfunction && $yfunction) { - my $f = $data->{function}; - my $color = $data->style('color'); - my $width = $data->style('width'); - my $scale = $data->style('scale'); - my $tikzOpts = $data->style('tikzOpts') || ''; - $tikzOpts = ", $tikzOpts" if $tikzOpts; + if ($xfunction ne '' && $yfunction ne '') { + my $f = $data->{function}; + my $color = $data->style('color'); + my $width = $data->style('width'); + my $scale = $data->style('scale'); + my $tikz_options = $data->style('tikz_options') ? ', ' . $data->style('tikz_options') : ''; $data->update_min_max; if ($data->style('normalize') || $data->style('slopefield')) { @@ -274,9 +270,9 @@ sub draw { $tikzCode .= "\\addplot3[color=$color, line width=${width}pt$arrows, " . "quiver={u=$xfunction, v=$yfunction, scale arrows=$scale}, samples=$f->{xsteps}, " - . "domain=$f->{xmin}:$f->{xmax}, domain y=$f->{ymin}:$f->{ymax}$tikzOpts] {1};\n"; + . "domain=$f->{xmin}:$f->{xmax}, domain y=$f->{ymin}:$f->{ymax}$tikz_options] {1};\n"; } else { - warn "Vector field not created due to missing JavaScript functions."; + warn "Vector field not created due to missing PGF functions."; } } @@ -295,16 +291,16 @@ sub draw { # Labels for my $label ($plots->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' : ''; + 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 $tikz_options = $label->style('tikz_options') || ''; + 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 ', @@ -314,10 +310,10 @@ sub draw { }->{$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"; + $tikz_options = $tikz_options ? "$color, $tikz_options" : $color; + $tikz_options = "anchor=$anchor, $tikz_options" if $anchor; + $tikz_options = "rotate=90, $tikz_options" if $orientation eq 'vertical'; + $tikzCode .= $self->get_color($color) . "\\node[$tikz_options] at (axis cs: $x,$y) {$str};\n"; } $tikzCode .= '\end{axis}' . "\n"; diff --git a/macros/graph/plots.pl b/macros/graph/plots.pl index 72e537161f..01374a54c1 100644 --- a/macros/graph/plots.pl +++ b/macros/graph/plots.pl @@ -19,23 +19,22 @@ =head1 USAGE First create a Plots object: loadMacros('plots.pl'); - $plot = Plot(); - -Configure the L: - - $plot->axes->xaxis( - min => 0, - max => 10, - tick_delta => 2, - label => '\(t\)', + $plot = Plot( + xmin => 0, + xmax => 10, + ymin => 0, + ymax => 500, + xtick_delta => 2, + ytick_delta => 50, + xlabel => '\(t\)', + ylabel => '\(h(t)\)', + title => 'Height of an object as a function of time.', + axes_on_top => 1, ); - $plot->axes->yaxis( - min => 0, - max => 500, - tick_delta => 50, - label => '\(h(t)\)' - ); - $plot->axes->style(title => 'Height of an object as a function of time.'); + +This single call configures the L (see link for full list of options). +Options that start with C configure the xaxis, options that start with C configure the +yaxis, and all other options are Axes styles. Add a function and other objects to the plot. @@ -75,9 +74,9 @@ =head2 DATASETS 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'], + [[0, 0], [2,3], color => 'green', end_mark => 'arrow'], + [[2, 3], [4,-1], color => 'blue', end_mark => 'arrow'], + [[0, 0], [4, -1], color => 'red', start_mark => 'arrow'], ); If needed, the C<< $plot->add_dataset >> method returns the L object @@ -214,6 +213,14 @@ =head2 PLOT VECTOR FIELDS Use this in combination with setting C 1> and C equal to the slope field formula to graph a slope field instead of a vector field. Default: 0 +=item jsx_options + +A hash reference of options to pass to the JSXGraph C object. + +=item tikz_options + +A string of TikZ options to append to the C<\addplot3> which creates the vector field quiver. + =back =head2 DATASET OPTIONS @@ -339,25 +346,21 @@ =head2 DATASET OPTIONS and can mess with fills. For functions you will need to explicitly turn it off in cases it has undesirable side effects. -=item continue_left - -If set to 1, the graph of a non-parametric function using JSXGraph will keep going -to the left beyond the minimum bound. This allows zooming out or panning the graph -to the left. Default: 0 - -=item continue_right +=item continue, continue_left, continue_right If set to 1, the graph of a non-parametric function using JSXGraph will keep going -to the right beyond the maximum bound. This allows zooming out or panning the graph -to the right. Default: 0 +both left and right beyond the bounds. This allows zooming out or panning the graph. +This requires the C style C set to 1. This option +implies both C and C, which can be used to extend +the function only one direction. Default: 0 -=item tikzOpts +=item jsx_options -Additional pgfplots C<\addplot> options to be added to the tikz output. +A hash reference of options to add to the JSXGraph output of the associated object. -=item JSXGraphOpts +=item tikz_options -A hash reference of options to pass to be added to the JSXGraph output. +Additional pgfplots C<\addplot> options to be appeneded to the tikz output. =back @@ -409,9 +412,13 @@ =head2 LABELS 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 +=item jsx_options + +An hash reference of options to pass to JSXGraph text object. + +=item tikz_options -Additional TikZ options to be used when adding the label using TikZ output via C<\node>. +Additional TikZ options to be appended to C<\node> when adding the label. =back @@ -500,4 +507,4 @@ BEGIN sub _plots_init { } -sub Plot { Plots::Plot->new($main::PG, @_); } +sub Plot { Plots::Plot->new(@_); } From d7fc52a8ece0b37c22233e793fc415bd4343e2cd Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Tue, 10 Jun 2025 17:11:19 -0500 Subject: [PATCH 09/19] Add a white background to pgplot TikZ output. Also, don't add prefixed css (like `-webkit-border-radius`). Autoprefixer adds any of those that are needed. Note that this one is not added because it actually isn't needed anymore (unless we really want to support really old browsers). --- htdocs/js/Plots/plots.scss | 1 - lib/Plots/Data.pm | 2 +- lib/Plots/Tikz.pm | 7 ++++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/htdocs/js/Plots/plots.scss b/htdocs/js/Plots/plots.scss index a7258fceca..14c0c8dee3 100644 --- a/htdocs/js/Plots/plots.scss +++ b/htdocs/js/Plots/plots.scss @@ -2,5 +2,4 @@ display: inline-block; margin: 1rem; border-radius: 0px; - -webkit-border-radius: 0px; } diff --git a/lib/Plots/Data.pm b/lib/Plots/Data.pm index e2a372becb..ac0a29209b 100644 --- a/lib/Plots/Data.pm +++ b/lib/Plots/Data.pm @@ -163,7 +163,7 @@ sub size { sub x { my ($self, $n) = @_; - return $self->{x}->[$n] if (defined($n) && defined($self->{x}->[$n])); + return $self->{x}[$n] if (defined($n) && defined($self->{x}[$n])); return wantarray ? @{ $self->{x} } : $self->{x}; } diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 18280d99a0..29a8d452f2 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -15,13 +15,14 @@ use warnings; sub new { my ($class, $plots) = @_; my $image = LaTeXImage->new; - $image->environment('tikzpicture'); + $image->environment([ 'tikzpicture', 'framed' ]); $image->svgMethod(eval('$main::envir{latexImageSVGMethod}') // 'dvisvgm'); $image->convertOptions(eval('$main::envir{latexImageConvertOptions}') // { input => {}, output => {} }); $image->ext($plots->ext); - $image->tikzLibraries('arrows.meta,plotmarks'); + $image->tikzLibraries('arrows.meta,plotmarks,backgrounds'); $image->texPackages(['pgfplots']); - $image->addToPreamble('\pgfplotsset{compat=1.18}\usepgfplotslibrary{fillbetween}'); + $image->addToPreamble('\pgfplotsset{compat=1.18}\usepgfplotslibrary{fillbetween}' + . '\tikzset{inner frame sep=0pt,background rectangle/.style={thick,draw=DarkBlue,fill=white}}'); return bless { image => $image, plots => $plots, colors => {} }, $class; } From c438d1b5b5e0b43a3d60cffca8ebe60e578f600b Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Tue, 10 Jun 2025 17:52:36 -0600 Subject: [PATCH 10/19] Redefine pgfplot standard and axis on top layers. pdfplot doesn't included a 'background' layer used for the framed background, which causes a conflict with fillbetween. This redefines the `standard` and `axis on top` layers and adds the background layer so it can be used to draw the framed background. --- lib/Plots/Tikz.pm | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 29a8d452f2..5f79b03714 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -21,8 +21,26 @@ sub new { $image->ext($plots->ext); $image->tikzLibraries('arrows.meta,plotmarks,backgrounds'); $image->texPackages(['pgfplots']); - $image->addToPreamble('\pgfplotsset{compat=1.18}\usepgfplotslibrary{fillbetween}' - . '\tikzset{inner frame sep=0pt,background rectangle/.style={thick,draw=DarkBlue,fill=white}}'); + + # Redefine standard layers due to conflict with fillbetween. + $image->addToPreamble('\usepgfplotslibrary{fillbetween}' + . '\tikzset{inner frame sep=0pt,background rectangle/.style={thick,draw=DarkBlue,fill=white}}' + . '\pgfplotsset{compat=1.18,layers/standard/.define layer set={' + . 'background,axis background,axis grid,axis ticks,axis lines,axis tick labels,' + . 'pre main,main,axis descriptions,axis foreground}{' + . 'grid style={/pgfplots/on layer=axis grid},' + . 'tick style={/pgfplots/on layer=axis ticks},' + . 'axis line style={/pgfplots/on layer=axis lines},' + . 'label style={/pgfplots/on layer=axis descriptions},' + . 'legend style={/pgfplots/on layer=axis descriptions},' + . 'title style={/pgfplots/on layer=axis descriptions},' + . 'colorbar style={/pgfplots/on layer=axis descriptions},' + . 'ticklabel style={/pgfplots/on layer=axis tick labels},' + . 'axis background@ style= {/pgfplots/on layer=axis background},' + . '3d box foreground style= {/pgfplots/on layer=axis foreground}},' + . 'layers/axis on top/.define layer set={' + . 'background,axis background,pre main,main,axis grid,axis ticks,axis lines,' + . 'axis tick labels,axis descriptions,axis foreground}{/pgfplots/layers/standard},}'); return bless { image => $image, plots => $plots, colors => {} }, $class; } From afbb6026ec0934569fb98ee9231c21c702ebd464 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Tue, 10 Jun 2025 21:26:39 -0500 Subject: [PATCH 11/19] Clean up the `addToPreamble` call. --- lib/Plots/Tikz.pm | 66 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 5f79b03714..340335ba9b 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -22,25 +22,51 @@ sub new { $image->tikzLibraries('arrows.meta,plotmarks,backgrounds'); $image->texPackages(['pgfplots']); - # Redefine standard layers due to conflict with fillbetween. - $image->addToPreamble('\usepgfplotslibrary{fillbetween}' - . '\tikzset{inner frame sep=0pt,background rectangle/.style={thick,draw=DarkBlue,fill=white}}' - . '\pgfplotsset{compat=1.18,layers/standard/.define layer set={' - . 'background,axis background,axis grid,axis ticks,axis lines,axis tick labels,' - . 'pre main,main,axis descriptions,axis foreground}{' - . 'grid style={/pgfplots/on layer=axis grid},' - . 'tick style={/pgfplots/on layer=axis ticks},' - . 'axis line style={/pgfplots/on layer=axis lines},' - . 'label style={/pgfplots/on layer=axis descriptions},' - . 'legend style={/pgfplots/on layer=axis descriptions},' - . 'title style={/pgfplots/on layer=axis descriptions},' - . 'colorbar style={/pgfplots/on layer=axis descriptions},' - . 'ticklabel style={/pgfplots/on layer=axis tick labels},' - . 'axis background@ style= {/pgfplots/on layer=axis background},' - . '3d box foreground style= {/pgfplots/on layer=axis foreground}},' - . 'layers/axis on top/.define layer set={' - . 'background,axis background,pre main,main,axis grid,axis ticks,axis lines,' - . 'axis tick labels,axis descriptions,axis foreground}{/pgfplots/layers/standard},}'); + # Set the pgfplots compatibility, add the pgfplots fillbetween library, set a nice rectangle frame with white + # background for the backgrounds library, and redefine standard layers since the backgrounds library uses layers + # that conflict with the layers used by the fillbetween library. + $image->addToPreamble( <<~ 'END_PREAMBLE'); + \usepgfplotslibrary{fillbetween} + \tikzset{inner frame sep = 0pt, background rectangle/.style = { thick, draw = DarkBlue, fill = white }} + \pgfplotsset{ + compat = 1.18, + layers/standard/.define layer set = { + background, + axis background, + axis grid, + axis ticks, + axis lines, + axis tick labels, + pre main, + main, + axis descriptions, + axis foreground + }{ + grid style = { /pgfplots/on layer = axis grid }, + tick style = { /pgfplots/on layer = axis ticks }, + axis line style = { /pgfplots/on layer = axis lines }, + label style = { /pgfplots/on layer = axis descriptions }, + legend style = { /pgfplots/on layer = axis descriptions }, + title style = { /pgfplots/on layer = axis descriptions }, + colorbar style = { /pgfplots/on layer = axis descriptions }, + ticklabel style = { /pgfplots/on layer = axis tick labels }, + axis background@ style = { /pgfplots/on layer = axis background }, + 3d box foreground style = { /pgfplots/on layer = axis foreground } + }, + layers/axis on top/.define layer set = { + background, + axis background, + pre main, + main, + axis grid, + axis ticks, + axis lines, + axis tick labels, + axis descriptions, + axis foreground + }{ /pgfplots/layers/standard } + } + END_PREAMBLE return bless { image => $image, plots => $plots, colors => {} }, $class; } @@ -334,7 +360,7 @@ sub draw { $tikz_options = "rotate=90, $tikz_options" if $orientation eq 'vertical'; $tikzCode .= $self->get_color($color) . "\\node[$tikz_options] at (axis cs: $x,$y) {$str};\n"; } - $tikzCode .= '\end{axis}' . "\n"; + $tikzCode .= '\end{axis}'; $plots->{tikzCode} = $tikzCode; $self->im->tex($tikzCode); From 3d2eb43567eaf32ad8df4a7e712c76f6f8b59e56 Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Tue, 10 Jun 2025 20:57:17 -0600 Subject: [PATCH 12/19] Remove ticks option from axes. Since JSXGraph didn't support custom ticks as well, remove it. Now the only way to configure the ticks is either via tick_num or tick_delta. Also in JSXGraph only show zero on the axis if the axis is not located at zero, or if jsx_navigation is set. --- lib/Plots/Axes.pm | 46 ++++++---------------- lib/Plots/GD.pm | 90 +++++++++++++++++++------------------------ lib/Plots/JSXGraph.pm | 16 ++++---- lib/Plots/Tikz.pm | 10 ++--- 4 files changed, 63 insertions(+), 99 deletions(-) diff --git a/lib/Plots/Axes.pm b/lib/Plots/Axes.pm index 97815b23af..f911a02e79 100644 --- a/lib/Plots/Axes.pm +++ b/lib/Plots/Axes.pm @@ -50,9 +50,9 @@ sent to C<< $plot->axes->style >>): The same methods also get the value of a single option, such as: - $xmin = $plot->axes->xaxis('min'); - $yticks = $plot->axes->yaxis('ticks'); - $show_grid = $plot->axes->style('show_grid'); + $xmin = $plot->axes->xaxis('min'); + $ytick_delta = $plot->axes->yaxis('tick_delta'); + $show_grid = $plot->axes->style('show_grid'); The methods without any inputs return a reference to the full hash, such as: @@ -64,7 +64,7 @@ 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'); + $grid = $plot->axes->get('xmajor', 'xminor', 'xtick_delta', 'ymajor', 'yminor', 'ytick_delta'); It is also possible to get the bounds as an array in the order xmin, ymin, xmax, ymax using the C<< $plot->axes->bounds >> method. @@ -92,17 +92,9 @@ and the number of ticks. Default: 5. =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 ticks - -An array which lists the major tick marks. If this array is empty, the ticks are -generated using either C or C. Note, JSXGraph doesn't support -this option, be sure to set C or C if using JSXGraph. -Default is C<[]>. - =item tick_labels This can be either 1 (show) or 0 (don't show) the labels for the major ticks. @@ -236,7 +228,6 @@ sub axis_defaults { label => $axis eq 'y' ? '\(y\)' : '\(x\)', location => $axis eq 'y' ? 'center' : 'middle', position => 0, - ticks => undef, tick_labels => 1, show_ticks => 1, tick_delta => 0, @@ -260,7 +251,7 @@ sub axis { 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}; + return $item eq 'tick_delta' ? $self->tick_delta($self->{$axis}) : $self->{$axis}{$item}; } sub xaxis { @@ -322,32 +313,17 @@ sub style { return $self->{styles}{$style}; } -sub gen_ticks { +sub tick_delta { my ($self, $axis) = @_; - my $min = $axis->{min}; - my $max = $axis->{max}; - my $delta = $axis->{tick_delta}; - unless ($delta) { - $delta = ($max - $min) / $axis->{tick_num}; - $axis->{tick_delta} = $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; + return $axis->{tick_delta} if $axis->{tick_delta}; + return 2 unless $axis->{tick_num}; + $axis->{tick_delta} = ($axis->{max} - $axis->{min}) / $axis->{tick_num} if $axis->{tick_num}; + return $axis->{tick_delta}; } sub grid { my $self = shift; - return $self->get('xmajor', 'xminor', 'xticks', 'ymajor', 'yminor', 'yticks'); + return $self->get('xmajor', 'xminor', 'xtick_delta', 'ymajor', 'yminor', 'ytick_delta'); } sub bounds { diff --git a/lib/Plots/GD.pm b/lib/Plots/GD.pm index 81f24c3256..eb5e9f27fa 100644 --- a/lib/Plots/GD.pm +++ b/lib/Plots/GD.pm @@ -242,71 +242,49 @@ sub draw { 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; + my $xminor = $grid->{xminor} || 0; + my $dx = $grid->{xtick_delta} || 1; + my $x = (int($xmax / $dx) + 1) * $dx; + my $end = (int($xmin / $dx) - 1) * $dx; + while ($x >= $end) { $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); - } + for (0 .. $xminor) { + my $tmp_x = $x + $_ * $dx / ($xminor + 1); + $self->moveTo($tmp_x, $ymin); + $self->lineTo($tmp_x, $ymax, $grid_color, 0.5, 1); } + $x -= $dx; } } 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; + my $yminor = $grid->{yminor} || 0; + my $dy = $grid->{ytick_delta} || 1; + my $y = (int($ymax / $dy) + 1) * $dy; + my $end = (int($ymin / $dy) - 1) * $dy; + while ($y >= $end) { $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); - } + for (0 .. $yminor) { + my $tmp_y = $y + $_ * $dy / ($yminor + 1); + $self->moveTo($xmin, $tmp_y); + $self->lineTo($xmax, $tmp_y, $grid_color, 0.5, 1); } + $y -= $dy; } } # 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'); + 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) { + if ($axes->xaxis('visible')) { 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'; @@ -320,9 +298,14 @@ sub draw { v_align => $label_align, h_align => $label_loc == $xmin ? 'left' : 'right' ); - for my $x (@{ $grid->{xticks} }) { + my $dx = $grid->{xtick_delta} || 1; + my $x = int($xmax / $dx) * $dx; + my $end = int($xmin / $dx) * $dx; + + while ($x >= $end) { $self->draw_label($x, $x, $ypos, font => 'large', v_align => $tick_align, h_align => 'center') - unless ($x == $xpos && $show_y); + unless $x == $xpos && $axes->yaxis('visible'); + $x -= $dx; } } if ($axes->yaxis('visible')) { @@ -339,9 +322,14 @@ sub draw { v_align => $label_loc == $ymin ? 'bottom' : 'top', h_align => $label_align ); - for my $y (@{ $grid->{yticks} }) { + + my $dy = $grid->{ytick_delta} || 1; + my $y = int($ymax / $dy) * $dy; + my $end = int($ymin / $dy) * $dy; + while ($y >= $end) { $self->draw_label($y, $xpos, $y, font => 'large', v_align => 'middle', h_align => $tick_align) - unless ($y == $ypos && $show_x); + unless $y == $ypos && $axes->xaxis('visible'); + $y -= $dy; } } diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index c4a4102157..867a844249 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -270,10 +270,10 @@ sub init_graph { # Adjust bounding box to add padding for axes at edge of graph. $xaxis_loc = 'bottom' if $xaxis_loc eq 'box'; $yaxis_loc = 'left' if $yaxis_loc eq 'box'; - $xmin -= 0.11 * ($xmax - $xmin) if $yaxis_loc eq 'left' || $xmin == $xaxis_pos; - $xmax += 0.11 * ($xmax - $xmin) if $yaxis_loc eq 'right' || $xmax == $xaxis_pos; - $ymin -= 0.11 * ($ymax - $ymin) if $xaxis_loc eq 'bottom' || $ymin == $yaxis_pos; - $ymax += 0.11 * ($ymax - $ymin) if $xaxis_loc eq 'top' || $ymax == $yaxis_pos; + $xmin -= 0.11 * ($xmax - $xmin) if $yaxis_loc eq 'left' || $xmin == $yaxis_pos; + $xmax += 0.11 * ($xmax - $xmin) if $yaxis_loc eq 'right' || $xmax == $yaxis_pos; + $ymin -= 0.11 * ($ymax - $ymin) if $xaxis_loc eq 'bottom' || $ymin == $xaxis_pos; + $ymax += 0.11 * ($ymax - $ymin) if $xaxis_loc eq 'top' || $ymax == $xaxis_pos; my $JSXOptions = Mojo::JSON::encode_json({ title => $axes->style('title') || 'Graph', @@ -300,8 +300,8 @@ sub init_graph { highlight => 0 }, ticks => { - drawLabels => $axes->xaxis('tick_labels') && $axes->xaxis('show_ticks') ? 1 : 0, - drawZero => $show_grid && $axes->xaxis('major') ? 1 : 0, + drawLabels => $axes->xaxis('tick_labels') && $axes->xaxis('show_ticks') ? 1 : 0, + drawZero => $axes->style('jsx_navigation') || $axes->yaxis('position') != 0 ? 1 : 0, insertTicks => 0, ticksDistance => $axes->xaxis('tick_delta'), majorHeight => $axes->xaxis('show_ticks') ? ($show_grid && $axes->xaxis('major') ? -1 : 10) : 0, @@ -325,8 +325,8 @@ sub init_graph { highlight => 0, }, ticks => { - drawLabels => $axes->yaxis('tick_labels') && $axes->yaxis('show_ticks') ? 1 : 0, - drawZero => $show_grid && $axes->yaxis('major') ? 1 : 0, + drawLabels => $axes->yaxis('tick_labels') && $axes->yaxis('show_ticks') ? 1 : 0, + drawZero => $axes->style('jsx_navigation') || $axes->xaxis('position') != 0 ? 1 : 0, insertTicks => 0, ticksDistance => $axes->yaxis('tick_delta'), majorHeight => $axes->yaxis('show_ticks') ? ($show_grid && $axes->yaxis('major') ? -1 : 10) : 0, diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 5f79b03714..cdce91f5b8 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -95,9 +95,9 @@ sub configure_axes { my $xminor = $show_grid && $xminor_num > 0 ? 'true' : 'false'; my $ymajor = $show_grid && $grid->{ymajor} ? 'true' : 'false'; my $yminor_num = $grid->{yminor}; - my $yminor = $show_grid && $yminor_num > 0 ? 'true' : 'false'; - my $xticks = $axes->xaxis('show_ticks') ? '{' . join(',', @{ $grid->{xticks} }) . '}' : 'none'; - my $yticks = $axes->yaxis('show_ticks') ? '{' . join(',', @{ $grid->{yticks} }) . '}' : 'none'; + my $yminor = $show_grid && $yminor_num > 0 ? 'true' : 'false'; + my $xticks = $axes->xaxis('show_ticks') ? "xtick distance=$grid->{xtick_delta}" : 'xticks=none'; + my $yticks = $axes->yaxis('show_ticks') ? "ytick distance=$grid->{ytick_delta}" : 'yticks=none'; my $xtick_labels = $axes->xaxis('tick_labels') ? '' : "\n\t\t\txticklabel=\\empty,"; my $ytick_labels = $axes->yaxis('tick_labels') ? '' : "\n\t\t\tyticklabel=\\empty,"; my $grid_color = $axes->style('grid_color'); @@ -142,8 +142,8 @@ sub configure_axes { axis y line=$axis_y_line$axis_y_pos, xlabel={$xlabel}, ylabel={$ylabel}, - xtick=$xticks,$xtick_labels - ytick=$yticks,$ytick_labels + $xticks,$xtick_labels + $yticks,$ytick_labels xmajorgrids=$xmajor, xminorgrids=$xminor, minor x tick num=$xminor_num, From ba3887c58c13612503280b1b974379fd9a4f1f72 Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Tue, 10 Jun 2025 21:20:15 -0600 Subject: [PATCH 13/19] Fix ariaDescription in Plots::JSXGraph. I toyed with the idea of making this an axes style, then changed my mind, but didn't fully revert my changes. --- lib/Plots/JSXGraph.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index 867a844249..c28c4f9d09 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -277,7 +277,7 @@ sub init_graph { my $JSXOptions = Mojo::JSON::encode_json({ title => $axes->style('title') || 'Graph', - description => $axes->style('ariaDescription'), + description => $plots->{ariaDescription}, boundingBox => [ $xmin, $ymax, $xmax, $ymin ], axis => 0, showNavigation => $allow_navigation, From 9838738d7bea691ab3f6db9ae34f544908c98d14 Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Tue, 10 Jun 2025 23:54:56 -0600 Subject: [PATCH 14/19] Update JSXGraph Axes and fix TikZ fill issue. * Update the anchors and offset to match JSXGraph default axes so labels appear on the correct grid lines better. * Move numbers to top when x-axis is at top and to the right when y-axis is at the right to better match PGFPlots. Remove mention of 'box' option from documentation since it isn't honored by JSXGraph (though still silently supported). * Adjust location of default x/y label. * Save all TikZ fills in a variable and only apply them at end of all other plots, this way if the fill is defined before the second named curve, TikZ graph doesn't error out. --- lib/Plots/Axes.pm | 8 ++--- lib/Plots/JSXGraph.pm | 76 +++++++++++++++++++++++++++++-------------- lib/Plots/Tikz.pm | 9 +++-- 3 files changed, 60 insertions(+), 33 deletions(-) diff --git a/lib/Plots/Axes.pm b/lib/Plots/Axes.pm index f911a02e79..a186155728 100644 --- a/lib/Plots/Axes.pm +++ b/lib/Plots/Axes.pm @@ -127,13 +127,11 @@ This sets if the axis is shown (1) or not (0) on the plot. Default is 1. 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' + xaxis => 'top', 'middle', 'bottom' + yaxis => '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. Note, 'box' is not supported -by JSXgraph, so when using JSXgraph, 'box' is equivalent to 'bottom' or 'left'. +are used, the axes appear on the inside of the graph at the appropriate axis position. Default 'middle' or 'center'. =item position diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index c28c4f9d09..f1d7863f14 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -266,10 +266,22 @@ sub init_graph { my $show_grid = $axes->style('show_grid'); my $allow_navigation = $axes->style('jsx_navigation') ? 1 : 0; my ($xmin, $ymin, $xmax, $ymax) = $axes->bounds; - - # Adjust bounding box to add padding for axes at edge of graph. $xaxis_loc = 'bottom' if $xaxis_loc eq 'box'; $yaxis_loc = 'left' if $yaxis_loc eq 'box'; + + # Determine if zero should be drawn on the axis. + my $x_draw_zero = + $axes->style('jsx_navigation') + || ($yaxis_loc eq 'center' && $yaxis_pos != 0) + || ($yaxis_loc eq 'left' && $ymin != 0) + || ($yaxis_loc eq 'right' && $ymax != 0) ? 1 : 0; + my $y_draw_zero = + $axes->style('jsx_navigation') + || ($xaxis_loc eq 'middle' && $xaxis_pos != 0) + || ($xaxis_loc eq 'bottom' && $xmin != 0) + || ($xaxis_loc eq 'top' && $xmax != 0) ? 1 : 0; + + # Adjust bounding box to add padding for axes at edge of graph. $xmin -= 0.11 * ($xmax - $xmin) if $yaxis_loc eq 'left' || $xmin == $yaxis_pos; $xmax += 0.11 * ($xmax - $xmin) if $yaxis_loc eq 'right' || $xmax == $yaxis_pos; $ymin -= 0.11 * ($ymax - $ymin) if $xaxis_loc eq 'bottom' || $ymin == $xaxis_pos; @@ -288,51 +300,65 @@ sub init_graph { $JSXOptions = "JXG.merge($JSXOptions, " . Mojo::JSON::encode_json($axes->style('jsx_options')) . ')' if $axes->style('jsx_options'); my $XAxisOptions = Mojo::JSON::encode_json({ - name => $axes->xaxis('label'), - withLabel => 1, - position => $xaxis_loc eq 'middle' ? 'sticky' : 'fixed', - anchor => $xaxis_loc eq 'top' ? 'left' : $xaxis_loc eq 'bottom' ? 'right' : 'right left', - visible => $axes->xaxis('visible') ? 1 : 0, - highlight => 0, - label => { - position => 'rt', - offset => [ -10, 10 ], + name => $axes->xaxis('label'), + withLabel => 1, + position => $xaxis_loc eq 'middle' ? 'sticky' : 'fixed', + anchor => $xaxis_loc eq 'top' ? 'left' : $xaxis_loc eq 'bottom' ? 'right' : 'right left', + visible => $axes->xaxis('visible') ? 1 : 0, + highlight => 0, + firstArrow => { size => 7 }, + lastArrow => { size => 7 }, + label => { + position => 'lrt', + offset => [ -4, 8 ], highlight => 0 }, ticks => { - drawLabels => $axes->xaxis('tick_labels') && $axes->xaxis('show_ticks') ? 1 : 0, - drawZero => $axes->style('jsx_navigation') || $axes->yaxis('position') != 0 ? 1 : 0, + drawLabels => $axes->xaxis('tick_labels') && $axes->xaxis('show_ticks') ? 1 : 0, + drawZero => $x_draw_zero, insertTicks => 0, ticksDistance => $axes->xaxis('tick_delta'), majorHeight => $axes->xaxis('show_ticks') ? ($show_grid && $axes->xaxis('major') ? -1 : 10) : 0, minorTicks => $axes->xaxis('major') ? $axes->xaxis('minor') : 0, minorHeight => $axes->xaxis('show_ticks') ? ($show_grid ? -1 : 7) : 0, - label => { highlight => 0 }, + label => { + highlight => 0, + anchorX => 'middle', + anchorY => $xaxis_loc eq 'top' ? 'bottom' : 'top', + offset => $xaxis_loc eq 'top' ? [ 0, 3 ] : [ 0, -3 ] + }, }, }); $XAxisOptions = "JXG.merge($XAxisOptions, " . Mojo::JSON::encode_json($axes->xaxis('jsx_options')) . ')' if $axes->xaxis('jsx_options'); my $YAxisOptions = Mojo::JSON::encode_json({ - name => $axes->yaxis('label'), - withLabel => 1, - position => $yaxis_loc eq 'center' ? 'sticky' : 'fixed', - anchor => $yaxis_loc eq 'center' ? 'right left' : $yaxis_loc, - visible => $axes->yaxis('visible') ? 1 : 0, - highlight => 0, - label => { + name => $axes->yaxis('label'), + withLabel => 1, + position => $yaxis_loc eq 'center' ? 'sticky' : 'fixed', + anchor => $yaxis_loc eq 'center' ? 'right left' : $yaxis_loc, + visible => $axes->yaxis('visible') ? 1 : 0, + highlight => 0, + firstArrow => { size => 7 }, + lastArrow => { size => 7 }, + label => { position => 'rt', - offset => [ 10, -10 ], + offset => [ 6, 0 ], highlight => 0, }, ticks => { - drawLabels => $axes->yaxis('tick_labels') && $axes->yaxis('show_ticks') ? 1 : 0, - drawZero => $axes->style('jsx_navigation') || $axes->xaxis('position') != 0 ? 1 : 0, + drawLabels => $axes->yaxis('tick_labels') && $axes->yaxis('show_ticks') ? 1 : 0, + drawZero => $y_draw_zero, insertTicks => 0, ticksDistance => $axes->yaxis('tick_delta'), majorHeight => $axes->yaxis('show_ticks') ? ($show_grid && $axes->yaxis('major') ? -1 : 10) : 0, minorTicks => $axes->yaxis('major') ? $axes->yaxis('minor') : 0, minorHeight => $axes->yaxis('show_ticks') ? ($show_grid ? -1 : 7) : 0, - label => { highlight => 0 }, + label => { + highlight => 0, + anchorX => $yaxis_loc eq 'right' ? 'left' : 'right', + anchorY => 'middle', + offset => $yaxis_loc eq 'right' ? [ 6, 0 ] : [ -6, 0 ] + }, }, }); $YAxisOptions = "JXG.merge($YAxisOptions, " . Mojo::JSON::encode_json($axes->yaxis('jsx_options')) . ')' diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index c30530fe48..584973faeb 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -236,8 +236,9 @@ sub get_plot_opts { } sub draw { - my $self = shift; - my $plots = $self->plots; + my $self = shift; + my $plots = $self->plots; + my $tikzFill = ''; # Reset colors just in case. $self->{colors} = {}; @@ -288,9 +289,11 @@ sub draw { my $fill_max = $data->style('fill_max'); my $fill_range = defined $fill_min && defined $fill_max ? ", soft clip={domain=$fill_min:$fill_max}" : ''; $opacity *= 100; - $tikzCode .= "\\addplot[$fill_color!$opacity] fill between[of=$name and $fill$fill_range];\n"; + $tikzFill .= "\\addplot[$fill_color!$opacity] fill between[of=$name and $fill$fill_range];\n"; } } + # Add fills last to ensure all named graphs have been plotted first. + $tikzCode .= $tikzFill; # Vector/Slope Fields for my $data ($plots->data('vectorfield')) { From 408b316671778f40e3cf2ef8d8ca3db100d1f858 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Wed, 11 Jun 2025 05:45:46 -0500 Subject: [PATCH 15/19] Minor things that need to be done. Remove the injection of `\t` in the JSXGraph and TikZ output. Do not attempt to make the output human readable. That is not a desired goal. In fact for the JavasScript output the goal should be to make the output as unreadable as possible. That code is visible to the student, and could be used in some cases to assist in finding the answer. For the TikZ code, the objective should be to make the result compile with as few characters as possible. TeX has limits on the number of characters it can handle in a given line and we don't want to press that limit. The important thing is TeX is that newlines (`\n`) are where they need to be. Remove the margin on the JSXGraph div. That is inconsistent with the other display output modes. --- htdocs/js/Plots/plots.scss | 1 - lib/Plots/Data.pm | 13 +++-- lib/Plots/JSXGraph.pm | 100 +++++++++++++++++-------------------- lib/Plots/Tikz.pm | 32 +++++------- 4 files changed, 65 insertions(+), 81 deletions(-) diff --git a/htdocs/js/Plots/plots.scss b/htdocs/js/Plots/plots.scss index 14c0c8dee3..19c3586878 100644 --- a/htdocs/js/Plots/plots.scss +++ b/htdocs/js/Plots/plots.scss @@ -1,5 +1,4 @@ .plots-jsxgraph { display: inline-block; - margin: 1rem; border-radius: 0px; } diff --git a/lib/Plots/Data.pm b/lib/Plots/Data.pm index ac0a29209b..d4e924c10d 100644 --- a/lib/Plots/Data.pm +++ b/lib/Plots/Data.pm @@ -176,17 +176,16 @@ sub 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); + if (ref($styles[0]) eq 'HASH') { + map { $self->{styles}{$_} = $styles[0]{$_} } keys %{ $styles[0] }; return; } - my $style = $styles[0]; - if (ref($style) eq 'HASH') { - map { $self->{styles}{$_} = $style->{$_}; } (keys %$style); + if (@styles % 2 == 0) { + my %style_hash = @styles; + map { $self->{styles}{$_} = $style_hash{$_} } keys %style_hash; return; } - return $self->{styles}{$style}; + return $self->{styles}{ $styles[0] }; } sub get_math_object { diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index f1d7863f14..e330ee53c2 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -32,19 +32,19 @@ sub HTML { my $name = $self->{name}; my ($width, $height) = $self->plots->size; - return < - -END_HTML + return <<~ "END_HTML"; +
+ + END_HTML } sub get_color { @@ -120,7 +120,6 @@ sub add_curve { $data_points = '[[' . join(',', $data->x) . '],[' . join(',', $data->y) . ']]'; } - $self->{JS} .= "\n\t\t"; if ($curve_name) { $self->{JS} .= "const curve_${curve_name}_$name = "; } @@ -140,50 +139,45 @@ sub add_curve { if ($fill eq 'xaxis') { $self->{JSend} .= - "\n\t\tconst fill_${curve_name}_$name = board_$name.create('curve', [[], []], $fillOptions);\n" - . "\t\tfill_${curve_name}_$name.updateDataArray = function () {\n" - . "\t\t\tconst points = curve_${curve_name}_$name.points"; + "const fill_${curve_name}_$name = board_$name.create('curve', [[], []], $fillOptions);" + . "fill_${curve_name}_$name.updateDataArray = function () {" + . "const points = curve_${curve_name}_$name.points"; if (defined $fill_min && defined $fill_max) { $self->{JSend} .= - ".filter(p => {\n" - . "\t\t\t\treturn p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false\n" - . "\t\t\t})"; + ".filter(p => {" + . "return p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false" . "})"; } $self->{JSend} .= - ";\n\t\t\tthis.dataX = points.map( p => p.usrCoords[1] );\n" - . "\t\t\tthis.dataY = points.map( p => p.usrCoords[2] );\n" - . "\t\t\tthis.dataX.push(points[points.length - 1].usrCoords[1], " - . "points[0].usrCoords[1], points[0].usrCoords[1]);\n" - . "\t\t\tthis.dataY.push(0, 0, points[0].usrCoords[2]);\n" - . "\t\t};\n" - . "\t\tboard_$name.update();"; + ";this.dataX = points.map( p => p.usrCoords[1] );" + . "this.dataY = points.map( p => p.usrCoords[2] );" + . "this.dataX.push(points[points.length - 1].usrCoords[1], " + . "points[0].usrCoords[1], points[0].usrCoords[1]);" + . "this.dataY.push(0, 0, points[0].usrCoords[2]);" . "};" + . "board_$name.update();"; } else { $self->{JSend} .= - "\n\t\tconst fill_${curve_name}_$name = board_$name.create('curve', [[], []], $fillOptions);\n" - . "\t\tfill_${curve_name}_$name.updateDataArray = function () {\n" - . "\t\t\tconst points1 = curve_${curve_name}_$name.points"; + "const fill_${curve_name}_$name = board_$name.create('curve', [[], []], $fillOptions);" + . "fill_${curve_name}_$name.updateDataArray = function () {" + . "const points1 = curve_${curve_name}_$name.points"; if (defined $fill_min && defined $fill_max) { $self->{JSend} .= - ".filter(p => {\n" - . "\t\t\t\treturn p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false\n" - . "\t\t\t})"; + ".filter(p => {" + . "return p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false" . "})"; } - $self->{JSend} .= ";\n\t\t\tconst points2 = curve_${fill}_$name.points"; + $self->{JSend} .= ";const points2 = curve_${fill}_$name.points"; if (defined $fill_min && defined $fill_max) { $self->{JSend} .= - ".filter(p => {\n" - . "\t\t\t\treturn p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false\n" - . "\t\t\t})"; + ".filter(p => {" + . "return p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false" . "})"; } $self->{JSend} .= - ";\n\t\t\tthis.dataX = points1.map( p => p.usrCoords[1] ).concat(" - . "points2.map( p => p.usrCoords[1] ).reverse());\n" - . "\t\t\tthis.dataY = points1.map( p => p.usrCoords[2] ).concat(" - . "points2.map( p => p.usrCoords[2] ).reverse());\n" - . "\t\t\tthis.dataX.push(points1[0].usrCoords[1]);\n" - . "\t\t\tthis.dataY.push(points1[0].usrCoords[2]);\n" - . "\t\t};\n" - . "\t\tboard_$name.update();"; + ";this.dataX = points1.map( p => p.usrCoords[1] ).concat(" + . "points2.map( p => p.usrCoords[1] ).reverse());" + . "this.dataY = points1.map( p => p.usrCoords[2] ).concat(" + . "points2.map( p => p.usrCoords[2] ).reverse());" + . "this.dataX.push(points1[0].usrCoords[1]);" + . "this.dataY.push(points1[0].usrCoords[2]);" . "};" + . "board_$name.update();"; } } } @@ -238,7 +232,7 @@ sub add_point { $pointOptions = "JXG.merge($pointOptions, " . Mojo::JSON::encode_json($data->style('jsx_options')) . ')' if $data->style('jsx_options'); - $self->{JS} .= "\n\t\tboard_$name.create('point', [$x, $y], $pointOptions);"; + $self->{JS} .= "board_$name.create('point', [$x, $y], $pointOptions);"; } sub add_points { @@ -365,11 +359,11 @@ sub init_graph { if $axes->yaxis('jsx_options'); $self->{JSend} = ''; - $self->{JS} = <{JS} = <<~ "END_JS"; + const board_$name = JXG.JSXGraph.initBoard('board_$name', $JSXOptions); + board_$name.create('axis', [[0, $xaxis_pos], [1, $xaxis_pos]], $XAxisOptions); + board_$name.create('axis', [[$yaxis_pos, 0], [$yaxis_pos, 1]], $YAxisOptions); + END_JS } sub draw { @@ -410,7 +404,7 @@ sub draw { $xfunction = $xtmp; } - $self->{JS} .= "\n\t\tboard_$name.create('vectorfield', [[(x,y) => $xfunction, (x,y) => $yfunction], " + $self->{JS} .= "board_$name.create('vectorfield', [[(x,y) => $xfunction, (x,y) => $yfunction], " . "[$f->{xmin}, $f->{xsteps}, $f->{xmax}], [$f->{ymin}, $f->{ysteps}, $f->{ymax}]], $options);"; } else { warn "Vector field not created due to missing JavaScript functions."; @@ -453,7 +447,7 @@ sub draw { $textOptions = "JXG.merge($textOptions, " . Mojo::JSON::encode_json($label->style('jsx_options')) . ')' if $label->style('jsx_options'); - $self->{JS} .= "\n\t\tboard_$name.create('text', [$x, $y, '$str'], $textOptions);"; + $self->{JS} .= "board_$name.create('text', [$x, $y, '$str'], $textOptions);"; } # JSXGraph only produces HTML graphs and uses TikZ for hadrcopy. diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 584973faeb..67f9d722c4 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -124,8 +124,8 @@ sub configure_axes { my $yminor = $show_grid && $yminor_num > 0 ? 'true' : 'false'; my $xticks = $axes->xaxis('show_ticks') ? "xtick distance=$grid->{xtick_delta}" : 'xticks=none'; my $yticks = $axes->yaxis('show_ticks') ? "ytick distance=$grid->{ytick_delta}" : 'yticks=none'; - my $xtick_labels = $axes->xaxis('tick_labels') ? '' : "\n\t\t\txticklabel=\\empty,"; - my $ytick_labels = $axes->yaxis('tick_labels') ? '' : "\n\t\t\tyticklabel=\\empty,"; + my $xtick_labels = $axes->xaxis('tick_labels') ? '' : "\nxticklabel=\\empty,"; + my $ytick_labels = $axes->yaxis('tick_labels') ? '' : "\nyticklabel=\\empty,"; my $grid_color = $axes->style('grid_color'); my $grid_color2 = $self->get_color($grid_color); my $grid_alpha = $axes->style('grid_alpha'); @@ -136,28 +136,22 @@ sub configure_axes { my $ylabel = $axes->yaxis('label'); my $axis_y_line = $axes->yaxis('location'); my $axis_y_pos = $axes->yaxis('position'); - my $axis_on_top = $axes->style('axis_on_top') ? "axis on top,\n\t\t\t" : ''; + my $axis_on_top = $axes->style('axis_on_top') ? "axis on top,\n" : ''; my $hide_x_axis = ''; my $hide_y_axis = ''; my $xaxis_plot = ($xmin <= 0 && $xmax >= 0) ? "\\path[name path=xaxis] ($xmin, 0) -- ($xmax,0);\n" : ''; - $axis_x_pos = $axis_x_pos ? ",\n\t\t\taxis x line shift=" . (-$axis_x_pos) : ''; - $axis_y_pos = $axis_y_pos ? ",\n\t\t\taxis y line shift=" . (-$axis_y_pos) : ''; + $axis_x_pos = $axis_x_pos ? ",\naxis x line shift=" . (-$axis_x_pos) : ''; + $axis_y_pos = $axis_y_pos ? ",\naxis y line shift=" . (-$axis_y_pos) : ''; 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,"; + $xlabel = ''; + $hide_x_axis = "\nx axis line style={draw=none},\n" . "x tick style={draw=none},\n" . "xticklabel=\\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,"; + $ylabel = ''; + $hide_y_axis = "\ny axis line style={draw=none},\n" . "y tick style={draw=none},\n" . "yticklabel=\\empty,"; } - my $tikzCode = < Date: Wed, 11 Jun 2025 08:41:06 -0500 Subject: [PATCH 16/19] Add imageview features to JSXGraph output of plots when the jsx_navigation option is false. There are also some changes for consistency between JSXGraqh and TikZ output. The axes arrows now only point in the positive direction, and extend to the edge of the board. With the the axes label positioning needed to be changed. Furthermore, the JSXGraph preferred method for setting the `position` option of a label is switched to (see https://jsxgraph.org/docs/symbols/Label.html#position). Also remove the `$name` suffixes to variables in the javascript. This javascript is scoped and that suffix is unnecessary. The variable names to not need to be distinct between different JSXGraph images with the scoping. Furthmore, it increases the size of the generated javascript which is not desirable. --- htdocs/js/ImageView/imageview.js | 22 +++++- lib/Plots/Axes.pm | 6 +- lib/Plots/JSXGraph.pm | 131 +++++++++++++++++++------------ 3 files changed, 104 insertions(+), 55 deletions(-) diff --git a/htdocs/js/ImageView/imageview.js b/htdocs/js/ImageView/imageview.js index e039efd02d..f78d8d791c 100644 --- a/htdocs/js/ImageView/imageview.js +++ b/htdocs/js/ImageView/imageview.js @@ -112,7 +112,15 @@ const body = document.createElement('div'); body.classList.add('modal-body'); - body.innerHTML = imgHtml; + + let graphDiv = null; + if (imgType == 'div') { + graphDiv = document.createElement('div'); + graphDiv.id = `magnified-${this.id}`; + body.append(graphDiv); + } else { + body.innerHTML = imgHtml; + } zoomInButton.append(zoomInSVG); zoomOutButton.append(zoomOutSVG); @@ -135,6 +143,9 @@ // This assumes the units of the view box dimensions are points. naturalWidth = (viewBoxDims.width * 4) / 3; naturalHeight = (viewBoxDims.height * 4) / 3; + } else if (imgType == 'div') { + naturalWidth = this.clientWidth * 1.2; + naturalHeight = this.clientHeight * 1.2; } const headerHeight = header.offsetHeight; @@ -151,6 +162,8 @@ let width = naturalWidth; let height = naturalHeight; + if (imgType == 'div') this.dispatchEvent(new Event('shown.imageview')); + // Dialog position let left; let top; @@ -199,6 +212,12 @@ dialog.style.width = width + 18 + 'px'; dialog.style.height = height + headerHeight + 18 + 'px'; + if (graphDiv) { + graphDiv.style.width = width + 'px'; + graphDiv.style.height = height + 'px'; + this.dispatchEvent(new Event('resized.imageview')); + } + // Re-position the modal. if (initial) { // Center the modal initially @@ -289,6 +308,7 @@ backdrop.style.opacity = '0.2'; }); modal.addEventListener('hidden.bs.modal', () => { + if (imgType == 'div') this.dispatchEvent(new Event('hidden.imageview')); bsModal.dispose(); modal.remove(); window.removeEventListener('resize', onWinResize); diff --git a/lib/Plots/Axes.pm b/lib/Plots/Axes.pm index a186155728..cd1952e973 100644 --- a/lib/Plots/Axes.pm +++ b/lib/Plots/Axes.pm @@ -181,8 +181,10 @@ be visible after the fill, otherwise the fill will cover the axis. Default: 0 =item jsx_navigation -Either allow (1) or don't allow (0) the user to pan and zoom the view port of the JSXGraph. -Best used when plotting functions with the C style. Default: 0 +Either allow (1) or don't allow (0) the user to pan and zoom the view port of the +JSXGraph. Best used when plotting functions with the C style. Note that if this +option is 0, then the image can be clicked on to open a dialog showing a magnified version +of the graph that can be zoomed in or out. Default: 0 =item jsx_options diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index e330ee53c2..97577ac631 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -32,16 +32,37 @@ sub HTML { my $name = $self->{name}; my ($width, $height) = $self->plots->size; + my $imageviewClass = $self->plots->axes->style('jsx_navigation') ? '' : ' image-view-elt'; + my $tabindex = $self->plots->axes->style('jsx_navigation') ? '' : ' tabindex="0"'; + return <<~ "END_HTML"; -
+
END_HTML @@ -73,7 +94,6 @@ sub add_curve { my $start = $data->style('start_mark') || ''; my $end = $data->style('end_mark') || ''; - my $name = $self->{name}; my $curve_name = $data->style('name'); my $color = $self->get_color($data->style('color') || 'default_color'); my $line_width = $data->style('width') || 2; @@ -121,9 +141,9 @@ sub add_curve { } if ($curve_name) { - $self->{JS} .= "const curve_${curve_name}_$name = "; + $self->{JS} .= "const curve_${curve_name} = "; } - $self->{JS} .= "board_$name.create('$type', $data_points, $plotOptions);"; + $self->{JS} .= "board.create('$type', $data_points, $plotOptions);"; $self->add_point($data, $data->get_start_point, $line_width, $start, $color) if $start =~ /circle/; $self->add_point($data, $data->get_end_point, $line_width, $end, $color) if $end =~ /circle/; if ($curve_name && $fill ne 'none' && $fill ne 'self') { @@ -139,9 +159,9 @@ sub add_curve { if ($fill eq 'xaxis') { $self->{JSend} .= - "const fill_${curve_name}_$name = board_$name.create('curve', [[], []], $fillOptions);" - . "fill_${curve_name}_$name.updateDataArray = function () {" - . "const points = curve_${curve_name}_$name.points"; + "const fill_${curve_name} = board.create('curve', [[], []], $fillOptions);" + . "fill_${curve_name}.updateDataArray = function () {" + . "const points = curve_${curve_name}.points"; if (defined $fill_min && defined $fill_max) { $self->{JSend} .= ".filter(p => {" @@ -153,18 +173,18 @@ sub add_curve { . "this.dataX.push(points[points.length - 1].usrCoords[1], " . "points[0].usrCoords[1], points[0].usrCoords[1]);" . "this.dataY.push(0, 0, points[0].usrCoords[2]);" . "};" - . "board_$name.update();"; + . "board.update();"; } else { $self->{JSend} .= - "const fill_${curve_name}_$name = board_$name.create('curve', [[], []], $fillOptions);" - . "fill_${curve_name}_$name.updateDataArray = function () {" - . "const points1 = curve_${curve_name}_$name.points"; + "const fill_${curve_name} = board.create('curve', [[], []], $fillOptions);" + . "fill_${curve_name}.updateDataArray = function () {" + . "const points1 = curve_${curve_name}.points"; if (defined $fill_min && defined $fill_max) { $self->{JSend} .= ".filter(p => {" . "return p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false" . "})"; } - $self->{JSend} .= ";const points2 = curve_${fill}_$name.points"; + $self->{JSend} .= ";const points2 = curve_${fill}.points"; if (defined $fill_min && defined $fill_max) { $self->{JSend} .= ".filter(p => {" @@ -177,7 +197,7 @@ sub add_curve { . "points2.map( p => p.usrCoords[2] ).reverse());" . "this.dataX.push(points1[0].usrCoords[1]);" . "this.dataY.push(points1[0].usrCoords[2]);" . "};" - . "board_$name.update();"; + . "board.update();"; } } } @@ -185,7 +205,6 @@ sub add_curve { sub add_point { my ($self, $data, $x, $y, $size, $mark, $color) = @_; my $fill = $color; - my $name = $self->{name}; if ($mark eq 'circle' || $mark eq 'closed_circle') { $mark = 'o'; @@ -232,7 +251,7 @@ sub add_point { $pointOptions = "JXG.merge($pointOptions, " . Mojo::JSON::encode_json($data->style('jsx_options')) . ')' if $data->style('jsx_options'); - $self->{JS} .= "board_$name.create('point', [$x, $y], $pointOptions);"; + $self->{JS} .= "board.create('point', [$x, $y], $pointOptions);"; } sub add_points { @@ -252,7 +271,6 @@ sub init_graph { my $self = shift; my $plots = $self->plots; my $axes = $plots->axes; - my $name = $self->{name}; my $xaxis_loc = $axes->xaxis('location'); my $yaxis_loc = $axes->yaxis('location'); my $xaxis_pos = $axes->xaxis('position'); @@ -265,12 +283,12 @@ sub init_graph { # Determine if zero should be drawn on the axis. my $x_draw_zero = - $axes->style('jsx_navigation') + $allow_navigation || ($yaxis_loc eq 'center' && $yaxis_pos != 0) || ($yaxis_loc eq 'left' && $ymin != 0) || ($yaxis_loc eq 'right' && $ymax != 0) ? 1 : 0; my $y_draw_zero = - $axes->style('jsx_navigation') + $allow_navigation || ($xaxis_loc eq 'middle' && $xaxis_pos != 0) || ($xaxis_loc eq 'bottom' && $xmin != 0) || ($xaxis_loc eq 'top' && $xmax != 0) ? 1 : 0; @@ -294,18 +312,23 @@ sub init_graph { $JSXOptions = "JXG.merge($JSXOptions, " . Mojo::JSON::encode_json($axes->style('jsx_options')) . ')' if $axes->style('jsx_options'); my $XAxisOptions = Mojo::JSON::encode_json({ - name => $axes->xaxis('label'), - withLabel => 1, - position => $xaxis_loc eq 'middle' ? 'sticky' : 'fixed', - anchor => $xaxis_loc eq 'top' ? 'left' : $xaxis_loc eq 'bottom' ? 'right' : 'right left', - visible => $axes->xaxis('visible') ? 1 : 0, - highlight => 0, - firstArrow => { size => 7 }, - lastArrow => { size => 7 }, - label => { - position => 'lrt', - offset => [ -4, 8 ], - highlight => 0 + name => $axes->xaxis('label'), + withLabel => 1, + position => $xaxis_loc eq 'middle' ? 'sticky' : 'fixed', + anchor => $xaxis_loc eq 'top' ? 'left' : $xaxis_loc eq 'bottom' ? 'right' : 'right left', + visible => $axes->xaxis('visible') ? 1 : 0, + highlight => 0, + firstArrow => 0, + lastArrow => { size => 7 }, + straightFirst => 0, + straightLast => 0, + label => { + anchorX => 'middle', + anchorY => 'middle', + position => '100% left', + offset => [ -10, 0 ], + highlight => 0, + useMathJax => 1 }, ticks => { drawLabels => $axes->xaxis('tick_labels') && $axes->xaxis('show_ticks') ? 1 : 0, @@ -326,18 +349,23 @@ sub init_graph { $XAxisOptions = "JXG.merge($XAxisOptions, " . Mojo::JSON::encode_json($axes->xaxis('jsx_options')) . ')' if $axes->xaxis('jsx_options'); my $YAxisOptions = Mojo::JSON::encode_json({ - name => $axes->yaxis('label'), - withLabel => 1, - position => $yaxis_loc eq 'center' ? 'sticky' : 'fixed', - anchor => $yaxis_loc eq 'center' ? 'right left' : $yaxis_loc, - visible => $axes->yaxis('visible') ? 1 : 0, - highlight => 0, - firstArrow => { size => 7 }, - lastArrow => { size => 7 }, - label => { - position => 'rt', - offset => [ 6, 0 ], - highlight => 0, + name => $axes->yaxis('label'), + withLabel => 1, + position => $yaxis_loc eq 'center' ? 'sticky' : 'fixed', + anchor => $yaxis_loc eq 'center' ? 'right left' : $yaxis_loc, + visible => $axes->yaxis('visible') ? 1 : 0, + highlight => 0, + firstArrow => 0, + lastArrow => { size => 7 }, + straightFirst => 0, + straightLast => 0, + label => { + anchorX => 'middle', + anchorY => 'middle', + position => '100% right', + offset => [ 6, -10 ], + highlight => 0, + useMathJax => 1 }, ticks => { drawLabels => $axes->yaxis('tick_labels') && $axes->yaxis('show_ticks') ? 1 : 0, @@ -360,17 +388,16 @@ sub init_graph { $self->{JSend} = ''; $self->{JS} = <<~ "END_JS"; - const board_$name = JXG.JSXGraph.initBoard('board_$name', $JSXOptions); - board_$name.create('axis', [[0, $xaxis_pos], [1, $xaxis_pos]], $XAxisOptions); - board_$name.create('axis', [[$yaxis_pos, 0], [$yaxis_pos, 1]], $YAxisOptions); + const board = JXG.JSXGraph.initBoard(id, $JSXOptions); + board.create('axis', [[$xmin, $xaxis_pos], [$xmax, $xaxis_pos]], $XAxisOptions); + board.create('axis', [[$yaxis_pos, $ymin], [$yaxis_pos, $ymax]], $YAxisOptions); END_JS } sub draw { my $self = shift; my $plots = $self->plots; - my $name = $plots->get_image_name =~ s/-/_/gr; - $self->{name} = $name; + $self->{name} = $plots->get_image_name =~ s/-/_/gr; $self->init_graph; @@ -404,7 +431,7 @@ sub draw { $xfunction = $xtmp; } - $self->{JS} .= "board_$name.create('vectorfield', [[(x,y) => $xfunction, (x,y) => $yfunction], " + $self->{JS} .= "board.create('vectorfield', [[(x,y) => $xfunction, (x,y) => $yfunction], " . "[$f->{xmin}, $f->{xsteps}, $f->{xmax}], [$f->{ymin}, $f->{ysteps}, $f->{ymax}]], $options);"; } else { warn "Vector field not created due to missing JavaScript functions."; @@ -447,7 +474,7 @@ sub draw { $textOptions = "JXG.merge($textOptions, " . Mojo::JSON::encode_json($label->style('jsx_options')) . ')' if $label->style('jsx_options'); - $self->{JS} .= "board_$name.create('text', [$x, $y, '$str'], $textOptions);"; + $self->{JS} .= "board.create('text', [$x, $y, '$str'], $textOptions);"; } # JSXGraph only produces HTML graphs and uses TikZ for hadrcopy. From a00bea6c3a42d6805ba2a5c55105fdfb0eac0e1e Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Wed, 11 Jun 2025 12:55:14 -0500 Subject: [PATCH 17/19] Wait until the containing div has size before drawing the board. This may fix the issue seen by @somiaj with the imageview dialog, but is needed even if it doesn't because if the containing div does not have size when the board is drawn the position of elements will be off, and that will happen, for instance, if the div is inside a scaffold. --- lib/Plots/JSXGraph.pm | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index 97577ac631..3da6d442c1 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -39,22 +39,35 @@ sub HTML {