From 98eb31e3dc76369ca0ebf985429117f113027679 Mon Sep 17 00:00:00 2001 From: Jaimos Skriletz Date: Sun, 10 Nov 2024 04:05:46 -0700 Subject: [PATCH] WIP: Add JSXGraph and Plotly.js graph output to Plots. --- conf/pg_config.dist.yml | 2 +- lib/Plots/Axes.pm | 2 +- lib/Plots/Data.pm | 9 ++- lib/Plots/JSX.pm | 110 ++++++++++++++++++++++++++++++++++ lib/Plots/Plot.pm | 56 +++++++++++++---- lib/Plots/Plotly.pm | 100 +++++++++++++++++++++++++++++++ macros/core/PGbasicmacros.pl | 12 +++- macros/graph/VectorField2D.pl | 4 +- macros/graph/plots.pl | 10 ++-- macros/graph/unionImage.pl | 6 +- 10 files changed, 282 insertions(+), 29 deletions(-) create mode 100644 lib/Plots/JSX.pm create mode 100644 lib/Plots/Plotly.pm diff --git a/conf/pg_config.dist.yml b/conf/pg_config.dist.yml index 359fa94aba..8484d612ea 100644 --- a/conf/pg_config.dist.yml +++ b/conf/pg_config.dist.yml @@ -230,7 +230,7 @@ modules: - [Multiple] - [PGrandom] - [Regression] - - ['Plots::Plot', 'Plots::Tikz', 'Plots::GD', 'Plots::Data', 'Plots::Axes'] + - ['Plots::Plot', 'Plots::Axes', 'Plots::Data', 'Plots::Tikz', 'Plots::JSX', 'Plots::Plotly', 'Plots::GD'] - [Select] - [Units] - [VectorField] diff --git a/lib/Plots/Axes.pm b/lib/Plots/Axes.pm index b65582dd77..cfa013e7c7 100644 --- a/lib/Plots/Axes.pm +++ b/lib/Plots/Axes.pm @@ -36,7 +36,7 @@ Hash of data for options for the general axis. =head1 USAGE -The axes object should be accessed through a PGplot object using C<< $plot->axes >>. +The axes object should be accessed through a Plots object using C<< $plot->axes >>. The axes object is used to configure and retrieve information about the axes, as in the following examples. diff --git a/lib/Plots/Data.pm b/lib/Plots/Data.pm index 89b7e180d3..c70e6cad98 100644 --- a/lib/Plots/Data.pm +++ b/lib/Plots/Data.pm @@ -15,11 +15,10 @@ =head1 DATA OBJECT -This object holds data about the different types of elements that can be added -to a PGplot graph. This is a hash with some helper methods. Data objects are created -and modified using the PGplot methods, and do not need to generally be -modified in a PG problem. Each PG add method returns the related data object which -can be used if needed. +This object holds data about the different types of elements that can be added to a +Plots graph. This is a hash with some helper methods. Data objects are created and +modified using the Plots methods, and do not need to generally be modified in a PG +problem. Each PG add method returns the related data object which can be used if needed. Each data object contains the following: diff --git a/lib/Plots/JSX.pm b/lib/Plots/JSX.pm new file mode 100644 index 0000000000..987ba2de06 --- /dev/null +++ b/lib/Plots/JSX.pm @@ -0,0 +1,110 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2023 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +=head1 DESCRIPTION + +This is the code that takes a C and creates a jsxgraph graph of the plot. + +See L for more details. + +=cut + +package Plots::JSX; + +use strict; +use warnings; + +sub new { + my ($class, $pgplot) = @_; + + $pgplot->insert_css('node_modules/jsxgraph/distrib/jsxgraph.css'); + $pgplot->insert_js('node_modules/jsxgraph/distrib/jsxgraphcore.js'); + + return bless { pgplot => $pgplot }, $class; +} + +sub pgplot { + my $self = shift; + return $self->{pgplot}; +} + +sub HTML { + my $self = shift; + my $board = $self->{board}; + my $JS = $self->{JS}; + + return < +(() => { + const draw_board = () => { +$JS + } + if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', draw_board); + else draw_board(); +})(); + +END_HTML +} + +sub init_graph { + my $self = shift; + my $pgplot = $self->pgplot; + my $axes = $pgplot->axes; + my $grid = $axes->grid; + my $name = $self->{name}; + my $title = $axes->style('title'); + my ($xmin, $ymin, $xmax, $ymax) = $axes->bounds; + my ($height, $width) = $pgplot->size; + my $style = 'display: inline-block; margin: 5px; text-align: center;'; + + $title = "$title" if $title; + $self->{board} = <$title +
+ +END_HTML + $self->{JS} = <pgplot; + my $name = $pgplot->get_image_name =~ s/-/_/gr; + $self->{name} = $name; + + $self->init_graph; + + # Plot Data + for my $data ($pgplot->data('function', 'dataset')) { + $data->gen_data; + $self->{JS} .= + "\n\t\tboard_$name.create('curve', [[" . (join(',', $data->x)) . "],[" . (join(',', $data->y)) . "]]);"; + } + + return $self->HTML; +} + +1; diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm index 6a314be2f5..9ebeb62712 100644 --- a/lib/Plots/Plot.pm +++ b/lib/Plots/Plot.pm @@ -29,6 +29,8 @@ use warnings; use Plots::Axes; use Plots::Data; use Plots::Tikz; +use Plots::Plotly; +use Plots::JSX; use Plots::GD; sub new { @@ -41,6 +43,7 @@ sub new { type => 'Tikz', ext => 'svg', size => [ $size, $size ], + tex_size => 500, axes => Plots::Axes->new, colors => {}, data => [], @@ -51,6 +54,24 @@ sub new { return $self; } +# Only insert js file if it isn't already inserted. +sub insert_js { + my ($self, $file) = @_; + for my $obj (@{ $self->{pg}{flags}{extra_js_files} }) { + return if $obj->{file} eq $file; + } + push(@{ $self->{pg}{flags}{extra_js_files} }, { file => $file, external => 0, attributes => { defer => undef } }); +} + +# Only insert css file if it isn't already inserted. +sub insert_css { + my ($self, $file) = @_; + for my $obj (@{ $self->{pg}{flags}{extra_css_files} }) { + return if $obj->{file} eq $file; + } + push(@{ $self->{pg}{flags}{extra_css_files} }, { file => $file, external => 0 }); +} + sub colors { my ($self, $color) = @_; return defined($color) ? $self->{colors}{$color} : $self->{colors}; @@ -131,14 +152,20 @@ sub image_type { # Check type and extension are valid. The first element of @validExt is used as default. my @validExt; $type = lc($type); - if ($type eq 'tikz') { + if ($type eq 'jsx') { + $self->{type} = 'JSX'; + @validExt = ('html'); + } elsif ($type eq 'plotly') { + $self->{type} = 'Plotly'; + @validExt = ('html'); + } elsif ($type eq 'tikz') { $self->{type} = 'Tikz'; @validExt = ('svg', 'png', 'pdf'); } elsif ($type eq 'gd') { $self->{type} = 'GD'; @validExt = ('png', 'gif'); } else { - warn "PGplot: Invalid image type $type."; + warn "Plots: Invalid image type $type."; return; } @@ -146,26 +173,29 @@ sub image_type { if (grep(/^$ext$/, @validExt)) { $self->{ext} = $ext; } else { - warn "PGplot: Invalid image extension $ext."; + warn "Plots: Invalid image extension $ext."; } } else { $self->{ext} = $validExt[0]; } + + # Hardcopy: Tikz needs to use the 'pdf' extension and fallback to Tikz output if ext is 'html'. + if ($self->{pg}{displayMode} eq 'TeX' && ($self->{ext} eq 'html' || $self->{type} eq 'Tikz')) { + $self->{type} = 'Tikz'; + $self->{ext} = 'pdf'; + } return; } -# Tikz needs to use pdf for hardcopy generation. sub ext { - my $self = shift; - return 'pdf' if ($self->{type} eq 'Tikz' && eval('$main::displayMode') eq 'TeX'); - return $self->{ext}; + return (shift)->{ext}; } # Return a copy of the tikz code (available after the image has been drawn). # Set $plot->{tikzDebug} to 1 to just generate the tikzCode, and not create a graph. sub tikz_code { my $self = shift; - return ($self->{tikzCode} && eval('$main::displayMode') =~ /HTML/) ? '
' . $self->{tikzCode} . '
' : ''; + return $self->{tikzCode} && $self->{pg}{displayMode} =~ /HTML/ ? '
' . $self->{tikzCode} . '
' : ''; } # Add functions to the graph. @@ -367,10 +397,14 @@ sub draw { my $type = $self->{type}; my $image; - if ($type eq 'GD') { - $image = Plots::GD->new($self); - } elsif ($type eq 'Tikz') { + if ($type eq 'Tikz') { $image = Plots::Tikz->new($self); + } elsif ($type eq 'JSX') { + $image = Plots::JSX->new($self); + } elsif ($type eq 'Plotly') { + $image = Plots::Plotly->new($self); + } elsif ($type eq 'GD') { + $image = Plots::GD->new($self); } else { warn "Undefined image type: $type"; return; diff --git a/lib/Plots/Plotly.pm b/lib/Plots/Plotly.pm new file mode 100644 index 0000000000..71d5df0e4b --- /dev/null +++ b/lib/Plots/Plotly.pm @@ -0,0 +1,100 @@ +################################################################################ +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2023 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +=head1 DESCRIPTION + +This is the code that takes a C and creates a Plotly.js graph of the plot. + +See L for more details. + +=cut + +package Plots::Plotly; + +use strict; +use warnings; + +sub new { + my ($class, $pgplot) = @_; + + $pgplot->insert_js('node_modules/plotly.js-dist-min/plotly.min.js'); + + return bless { pgplot => $pgplot, plots => [] }, $class; +} + +sub pgplot { + my $self = shift; + return $self->{pgplot}; +} + +sub HTML { + my $self = shift; + my $pgplot = $self->pgplot; + my $axes = $pgplot->axes; + my $grid = $axes->grid; + my $name = $pgplot->get_image_name =~ s/-/_/gr; + my $title = $axes->style('title'); + my $plots = ''; + my ($xmin, $ymin, $xmax, $ymax) = $axes->bounds; + my ($height, $width) = $pgplot->size; + my $style = 'border: solid 2px; display: inline-block; margin: 5px; text-align: center;'; + + $title = "$title" if $title; + for (@{ $self->{plots} }) { + $plots .= $_; + } + + return <$title +
+ + +END_HTML +} + +sub draw { + my $self = shift; + my $pgplot = $self->pgplot; + + # Plot Data + for my $data ($pgplot->data('function', 'dataset')) { + $data->gen_data; + + my $x_points = join(',', $data->x); + my $y_points = join(',', $data->y); + my $plot = <{plots} }, $plot); + } + + return $self->HTML; +} + +1; diff --git a/macros/core/PGbasicmacros.pl b/macros/core/PGbasicmacros.pl index 89fde80139..f7ba8b97a4 100644 --- a/macros/core/PGbasicmacros.pl +++ b/macros/core/PGbasicmacros.pl @@ -2909,9 +2909,19 @@ sub image { ); next; } + if (ref $image_item eq 'Plots::Plot') { + # Update image size as needed. + $image_item->{size}->[0] = $width if $out_options{width}; + $image_item->{size}->[1] = $height if $out_options{height}; + + if ($image_item->ext eq 'html') { + push(@output_list, $image_item->draw); + next; + } + } $image_item = insertGraph($image_item) if (ref $image_item eq 'WWPlot' - || ref $image_item eq 'PGplot' + || ref $image_item eq 'Plots::Plot' || ref $image_item eq 'PGlateximage' || ref $image_item eq 'PGtikz'); my $imageURL = alias($image_item) // ''; diff --git a/macros/graph/VectorField2D.pl b/macros/graph/VectorField2D.pl index 58121bda40..04c0e5402f 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..90ec1bfac3 100644 --- a/macros/graph/plots.pl +++ b/macros/graph/plots.pl @@ -19,14 +19,14 @@ =head1 NAME =head1 DESCRIPTION -This macro creates a Plot object that is used to add data of different +This macro creates a Plots object that is used to add data of different elements of a 2D plot, then draw the plot. The plots can be drawn using different -formats. Currently the legacy GD graphics format and TikZ (using pgfplots) -are available. +formats. Currently C (using pgfplots), C (using jsxgraph), C +(using Plotly.js), and the legacy C graphics format are available. =head1 USAGE -First create a PGplot object: +First create a Plots object: loadMacros('plots.pl'); $plot = Plot(); @@ -60,7 +60,7 @@ =head1 USAGE =head1 PLOT ELEMENTS A plot consists of multiple L objects, which define datasets, functions, -and labels to add to the graph. Data objects should be created though the PGplot object, +and labels to add to the graph. Data objects should be created though the Plots object, but can be access directly if needed =head2 DATASETS diff --git a/macros/graph/unionImage.pl b/macros/graph/unionImage.pl index f1b434409d..f4438dc59b 100644 --- a/macros/graph/unionImage.pl +++ b/macros/graph/unionImage.pl @@ -66,12 +66,12 @@ sub Image { my $TeX; ($image, $ilink) = @{$image} if (ref($image) eq "ARRAY"); $ilink = $ilink // ''; - $image = alias(insertGraph($image)) if (ref($image) eq "WWPlot" || ref($image) eq 'PGplot'); - $image = alias($image) unless ($image =~ m!^(/|https?:)!i); # see note + $image = alias(insertGraph($image)) if (ref($image) eq "WWPlot" || ref($image) eq 'Plots::Plot'); + $image = alias($image) unless ($image =~ m!^(/|https?:)!i); # see note if ($ilink) { $ilink = alias(insertGraph($ilink)) if (ref($ilink) eq "WWPlot"); - $ilink = alias($ilink) unless ($ilink =~ m!^(/|https?:)!i); # see note + $ilink = alias($ilink) unless ($ilink =~ m!^(/|https?:)!i); # see note } else { $ilink = $image; }