From f3a870d7c7f2bfae8db4f4b42395cfe3e469c881 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Wed, 8 Oct 2025 07:03:04 -0500 Subject: [PATCH 01/10] Fix some issues with plots.pl and the graph tool. First, fix the alt attribute for TikZ and GD image types for plots.pl. The `image` method of `PGbasicmacros.pl` currently sets the `aria_description` with the value of the `alt` option if it is passed to the method, and it does so regardless of the plots image type. However, that is only used for `html` output with JSXGraph. Thus for TikZ or GD image types the alt attribute is lost. This makes it so that the `aria_description` is only set for `html` output, and for all other outputs the given alt tag is left in the `@alt_list` so that the later code that inserts the `` tag can get it and add it as an attribute to the `` tag. You can test this with the following MWE: ```perl DOCUMENT(); loadMacros('PGstandard.pl', 'PGML.pl', 'plots.pl', 'PGcourse.pl'); $plot = Plot(); $plot->add_function('x^2', 'x', -10, 10, color => 'blue'); $plot->image_type('tikz'); BEGIN_PGML [!graph of x^2!]{$plot}{300} END_PGML ENDDOCUMENT(); ``` With that example and the current code, the image will not have an alt attribute, but will with this pull request. If you remove the line that sets the image type to 'tikz', then the JSXGraph image will get the aria description (with the second fix below). Second, fix the aria description for both JSXGraph output of plots.pl and the graph tool. This is caused by the removal of the `description` option for the `JXG.Board` object in the JSXGraph library. I must have missed this when this happened three years ago. Although, it seems to have been done rather quietly, as this is not listed in the change log for JSXGraph. To fix this, I just do the same thing that the `description` option used to do, and add a visually hidden span that the graph is `aria-describedby`. Note that there is a new `aria-description` attribute that could be used in the future for this, but it is in a future aria specification, and I don't know how well supported it is at this point. Finally, fix some issues with GD output of the plots.pl macro. This is caused when an Plots::Plot object does not have the height explicitly set. For TikZ and JSXGraph output, the `size` method is called which determines the height if it is not set explicitly. So GD output should do the same. --- htdocs/js/GraphTool/graphtool.js | 9 ++++++++- lib/Plots/GD.pm | 14 +++++++------- lib/Plots/JSXGraph.pm | 7 ++++++- lib/Plots/Tikz.pm | 2 +- macros/core/PGbasicmacros.pl | 2 +- 5 files changed, 23 insertions(+), 11 deletions(-) diff --git a/htdocs/js/GraphTool/graphtool.js b/htdocs/js/GraphTool/graphtool.js index 8edb82c3c7..c674837746 100644 --- a/htdocs/js/GraphTool/graphtool.js +++ b/htdocs/js/GraphTool/graphtool.js @@ -69,7 +69,6 @@ window.graphTool = (containerId, options) => { if ('htmlInputId' in options) gt.html_input = document.getElementById(options.htmlInputId); const cfgOptions = { title: 'WeBWorK Graph Tool', - description: options.ariaDescription ?? 'Interactively graph objects', showCopyright: false, pan: { enabled: false }, zoom: { enabled: false }, @@ -116,6 +115,14 @@ window.graphTool = (containerId, options) => { const setupBoard = () => { gt.board = JXG.JSXGraph.initBoard(`${containerId}_graph`, cfgOptions); + + const descriptionSpan = document.createElement('span'); + descriptionSpan.id = `${containerId}_description`; + descriptionSpan.classList.add('visually-hidden'); + descriptionSpan.textContent = options.ariaDescription ?? 'Interactively graph objects'; + gt.board.containerObj.after(descriptionSpan); + gt.board.containerObj.setAttribute('aria-describedby', descriptionSpan.id); + gt.board.suspendUpdate(); // Move the axes defining points to the end so that the arrows go to the board edges. diff --git a/lib/Plots/GD.pm b/lib/Plots/GD.pm index 5b75c34640..800374a002 100644 --- a/lib/Plots/GD.pm +++ b/lib/Plots/GD.pm @@ -63,7 +63,8 @@ sub im_y { return unless defined($y); my $plots = $self->plots; my ($ymin, $ymax) = ($plots->axes->yaxis('min'), $plots->axes->yaxis('max')); - return int(($ymax - $y) * $plots->{height} / ($ymax - $ymin)); + (undef, my $height) = $plots->size; + return int(($ymax - $y) * $height / ($ymax - $ymin)); } sub moveTo { @@ -217,12 +218,11 @@ sub draw_circle_stamp { } sub draw { - my $self = shift; - my $plots = $self->plots; - my $axes = $plots->axes; - my $grid = $axes->grid; - my $width = $plots->{width}; - my $height = $plots->{height}; + my $self = shift; + my $plots = $self->plots; + my $axes = $plots->axes; + my $grid = $axes->grid; + my ($width, $height) = $plots->size; # Initialize image $self->im->interlaced('true'); diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index f4d386360f..6b531d8612 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -404,7 +404,6 @@ sub init_graph { my $JSXOptions = Mojo::JSON::encode_json({ title => $axes->style('aria_label'), - description => $axes->style('aria_description'), boundingBox => [ $xmin, $ymax, $xmax, $ymin ], axis => 0, showNavigation => $allow_navigation, @@ -497,6 +496,12 @@ sub init_graph { $self->{JSend} = ''; $self->{JS} = <<~ "END_JS"; const board = JXG.JSXGraph.initBoard(id, $JSXOptions); + const descriptionSpan = document.createElement('span'); + descriptionSpan.id = `\${id}_description`; + descriptionSpan.classList.add('visually-hidden'); + descriptionSpan.textContent = '${\($axes->style('aria_description'))}'; + board.containerObj.after(descriptionSpan); + board.containerObj.setAttribute('aria-describedby', descriptionSpan.id); board.suspendUpdate(); board.create('axis', [[$xmin, $xaxis_pos], [$xmax, $xaxis_pos]], $XAxisOptions); board.create('axis', [[$yaxis_pos, $ymin], [$yaxis_pos, $ymax]], $YAxisOptions); diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 37cbcd6b78..08d18eacbf 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -221,7 +221,7 @@ sub get_plot_opts { } my $end_markers = ($start || $end) ? ", $start-$end" : ''; $marks = $self->get_mark($marks); - $marks = $marks ? $mark_size ? ", mark=$marks, mark size=${mark_size}px" : ", mark=$marks" : ''; + $marks = $marks ? $mark_size ? ", mark=$marks, mark size=${mark_size}pt" : ", mark=$marks" : ''; $linestyle =~ s/ /_/g; $linestyle = { diff --git a/macros/core/PGbasicmacros.pl b/macros/core/PGbasicmacros.pl index 94b8e5657c..6822f0f6b6 100644 --- a/macros/core/PGbasicmacros.pl +++ b/macros/core/PGbasicmacros.pl @@ -2928,9 +2928,9 @@ sub image { $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->axes->style(aria_description => shift @alt_list) if $out_options{alt}; if ($image_item->ext eq 'html') { + $image_item->axes->style(aria_description => shift @alt_list) if $out_options{alt}; $image_item->{description_details} = $description_details; push(@output_list, $image_item->draw); next; From ad99ad44b79985d68b708043795e1d21d8a71432 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Fri, 10 Oct 2025 07:21:23 -0500 Subject: [PATCH 02/10] Improvements to the plots.pl macro. The first objective of this pull request is to make the output of the JSXGraph and TikZ formats for the plots.pl macro more consistent. The intent is that JSXGraph will be the primary display mode used in HTML, and TikZ the display mode in hardcopy, and the two should produce images that are a close as possible to the same. These changes are summarized as follows: * Switch the JSXGraph arrows used for curve end markers from type 4 to type 2, and tweak the TikZ arrows to match. The type 4 arrows look quite bad on curves in almost all cases. The problem is that the curve line goes all the way to the end of the arrow, and so with the really sharp arrow point of the type 4 arrow the curve can be seen sticking out past the end of the arrow. The type 2 arrows are a little better, although they still have some of the same problem. The JSXGraph documentation recommends using type 7 arrows for this reason, but those are just ugly. * Tweak the sizes of marks (or points) for the two formats. Note that the line width is now taken into account for points. TikZ does this by default for marks on a curve, but now also does this for all points even those that don't belong to a curve. JSXGraph also now takes this into account. * Open marks (`open_circle`, `open_square`, `open_triangle`, and `open_diamond`) are now implemented as the closed variant filled with white. That is how it was done already for JSXGraph, but it is now also done that way for TikZ. I have seen the same issue with TikZ that was seen with the JSXGraph format that led to doing this there. That is the curve poking into the open part of the mark. * The JSXGraph output now has ticks as well as the grid just like the pgfplots images. The tick sizes for both formats were tweaked for consistency. * The JSXGraph grid is now limited within the axes in the same way that the pgfplots grid is. Also, the JSXGraph format places the axes labels on the edges in the cases that pgfplots does so. Additiionally, the JSXGraph format no longer reserves additional space for an axis in the cases that the axis is not shown. This again is the same as what pgfplots does. * Vector/slope fields in tikz are now drawn 2 dimensionally (i.e., via addplot instead of addplot3) for pgfplots. In order to get the entire field the slope field at each level is drawn. This also means that the slope field now correctly honors the ysteps setting. Furthermore, using `addplot3` messes with the 2 dimensional axes. pgfplots automatically changes the way things are drawn as soon as `addplot3` is called. One obvious issue is that tick labels on the axes are not drawn when this is done. * The `linestyle => 'none'` setting now works differently. The previous approach was inconsistent between the two formats. It set `only marks` for TikZ, but didn't draw curves at all for JSXGraph. That means that marks would be drawn for TikZ, but nothing for JSXGraph. Also, if `fill => 'self'` was also set, then the fill would appear in TikZ, but still nothing in JSXGraph. So now `only marks` is not set for TikZ, and instead `draw=none` is set, and for JSXGraph the curve is drawn, but with `strokeWidth: 0`. So now if `linestyle => 'none'` is set, then no marks are shown (unless the `marks` option is also given) and that is consistent for both formats. If `fill => 'self'` is also set, then the fill appears for both formats, still with no curve drawn. The second objective is to add some new features and improve the way the macro works. Those changes are as follows: * Allow the grid to be shown without the axes. The `plots.pl` documentation does not indicate that hiding the axes also hides the grid, but it does. That is not desirable, and there are images in which one might want the grid without the axes. * Add axis `minor_grids` option. If this is 1 (the default), then grid lines are shown at minor tick locations, and otherwise they are not. This allows having minor ticks without associated grid lines which is sometimes desirable. The `minor` option still is the number of minor ticks (and minor grid lines if `minor_grids` is 1) and its documentation is updated to state this (instead of saying it is the number of minor grid lines even though it really was both). * Tick labels are now displayed with MathJax by default in the JSXGraph format. This can be disabled by setting the `mathajx_tick_labels` axes style option to 0. * The way the `add_label` method should be called is changed. Instead of `$plot->add_label($y, $y, label => $label, %options);` use `$plot->add_label($y, $y, $label, %options);` The first way will still work, but the second is how it should be done. The `$label` argument which is the text of the label and is an essential argument for a label, should not be an optional parameter. * Add a `rounded_corners` option to round the corners on images. This is a general option that is passed to the `Plot` method. To make this work well the `framed` TikZ package cannot be used anymore. Instead the pgfplots axes is drawn in a `savebox`. Then the axes dimensions can be obtained and used to fill the box (with the corners clipped if corners are rounded) before the save box is actually rendered, and then draw the boundary box (again with rounded corners if desired). * Add the new axes style option `axes_arrows_both` to have arrows in both directions on the axes. * Move the JSXGraph board setup into a JavaScript file. The macro just passes the options for the board to the JavaScript. This results in a much smaller footprint in the generated page HTML sent to the browser, particularly if multiple images are in one problem. In addition, more features can be added in the JavaScript without adding to that footprint (such as the code for displaying tick labels as fractions, mixed numbers, and scientific notation -- see below). The new JavaScript file and the `jsxgraphcore.js` file both have the `defer` attribute. The `jsxgraphcore.js` file should have been loaded deferred before. * There are no font sizes corresponding to all of the basic TeX font size declarations except `scriptsize` and `footnotesize`. So 'tiny', 'small', 'normalsize', 'large', 'Large', 'huge', and 'Huge' are the available font sizes. The `medium` and `giant` sizes from before are marked as deprecated, but still work. `normalsize` replaces `medium` and `Large` replaces `giant`. The reason that `scriptsize` and `footnotesize` were not included is because there isn't really room between `tiny` (8) and `small` (10) in the JSXGraph translation of sizes to put anything in between. I suppose one could be added at size 9, but you can barely see the difference, and at such small sizes I am not sure it matters. * Add an `add_point` method, and deprecate the `add_stamp` method. The points added by the `add_point` method are basically datasets consisting of a single point, but are drawn after everything else so that they appear on top. * Vector/slope fields are drawn in the order that the author adds them to the plot. Previously they were drawn after everything else which was just wrong. That meant that if a curve was added to the plot after a vector field it would be drawn behind the vector field (barring the use of a layer), and that really should not be the case. This is also needed in the code to ensure that points are drawn after everything else, and the reuse the existing dataset drawing code. * An invalid color name no longer causes the problem to fail to render. Furthermore, SVG color names can be used directly without being defined by the `add_color` method. See section 4.3 of the TeX xcolor package for a list of SVG color names (https://ctan.mirrors.hoobly.com/macros/latex/contrib/xcolor/xcolor.pdf). Those work for both TikZ and JSXGraph directly. * Add `layer` and `fill_layer` options. This allows fill regions to be drawn on the axis background layer, and is a much better approach than using the `axis_on_top` option. Using the `axis_on_top` option results in the axis being on top of all curves and function graphs, and generally looks bad. In addition, the `axis_on_top` option is not implemented at all for the JSXGraph format. By using layers the fill can be drawn on the background and the curve on the foreground. Note that the "standard" layer set for the TikZ format is now different than the pgfplots default. The "axis tick labels" is after the "pre main" and "main" layers. This is consistent with where JSXGraph places them, and is better than what pgplots does. Axis tick labels are textual elements that should be in front of the things that are drawn, together with the "axis descriptions". On the other hand, the JSXGraph axis layer is adjusted to match the pgfplot axis layer, which is above the axis tick layer. Further adjustments may be needed, but for now this gives a rather consistent match up. I decided to leave the general `layer` option exposing all layers (we discussed turning that into a `draw_on_background` option only). Instead I tweaked the pgfplots standard layer and the JSXGrpah default layers to make them more consistent. Also, I saw another use where another layer is best. That is for vector/slope fields. Those should be drawn on the `pre main` layer so that the arrows are in front of the grid and axis lines, but behind other curves and textual components such as the tick labels and axis labels. * The fill between fill regions are no longer deferred until after everything else is drawn. That causes unintended side effects. Particularly, it is inconsistent with how `fill => 'self'` is done. In that case the fill is done immediately. As a result if both a "self" fill and a "fill between" fill are used, then the "self" fill ends up behind the "fill between" fill regardless of the order the two are created. So this respects the order of creation which is the author's intended order. Note that to protect against this the names of datasets that have been created are tracked, and if an author attempts to fill between a dataset and another dataset that has not yet been created, then the fill is not created and a warning is issued. * The documented default for the `arrow_size` option was 10. That was the default for the TikZ format, but the actual JSXGraph default was 8. The two formats certainly cannot use different defaults. So now the default is 8 for both formats and documented as such. Furthermore, with the mark size tweaks mentioned earlier, that default (and other size settings) are similar for both formats. * Add tick_distance, tick_scale, and tick_scale_symbol options. The `tick_distance` and `tick_scale` options give more fine grained control over tick placement than the former `tick_delta` option. The `tick_delta` option is all but deprecated (but I did not say so). The `tick_delta` is the product of the `tick_distance` and the `tick_scale`. The point is that the `tick_distance`, `tick_scale`, and `tick_scale_symbol` can be used to do things such as having ticks at multiples of `pi` and labeled as such. For example, if `tick_distance => 1 / 4`, `tick_scale => pi`, and `tick_scale_symbol => '\pi'`, then the ticks will be labeled `0.25\pi`, `0.5\pi`, `0.75\pi`, `\pi`, etc., and of course these ticks will appear at those actual distances on the axis (the `tick_delta` will be `pi / 4`). * Add axis `tick_label_format` option. This can be one of "decimal", "fraction", "mixed", or "scinot" (default is "decimal"). It should be clear what those values mean. Note that this works well with the above options. So with the example for those options above and `tick_label_format => "fraction"`, the tick labels will be `\frac{1}{4}\pi`, `\frac{1}{2}\pi`, `\frac{3}{4}\pi`, `\pi`, etc. * Add `extra_js_code` and `extra_tikz_code` options. These can be used to add extra JavaScript or TikZ code to draw things that are not covered by the macro directly. These are advanced options that should be used with care, only by those that really know what they are doing, and always both options used together to keep the JSXGraph and TikZ output formats the same. * Fix a bug that prevented functions defined by Perl functions from appearing at all in the TikZ format. * Some issues with the `Plots::Data::function_string` method were fixed. First the absolute value was not working. The issue is the the absolute value in a MathObject does not stringify as the `abs` function. Something like `abs(x)` stringifies as `|x|`. The `function_string` parsing approach cannot handle something like that. To fix this a new `stringifyAbsAsFunction` context flag was added, and if that flag is set for the context the absolute value stringifies as `abs`. So `abs(x)` stringifies as `abs(x)`. In addition there are no JavaScript functions `Math.ln`, `Math.arcsosh`, or `Math.arctanh`. So those "tokens" were fixed with the correct JavaScript functions which are `Math.log` (which is the natural log), `Math.acosh`, and `Math.atanh`, respectively. Note that the `GD` image format (the `Plots::GD` package) for the plots macro has been removed. That format shouldn't be used anyway as it generates low quality graphics (at least in its current form). --- conf/pg_config.dist.yml | 2 +- htdocs/js/Plots/plots.js | 487 +++++++++++++++++++++++ htdocs/js/Plots/plots.scss | 6 +- lib/Parser/Context/Default.pm | 1 + lib/Parser/Function/numeric.pm | 2 + lib/Plots/Axes.pm | 164 ++++++-- lib/Plots/Data.pm | 22 +- lib/Plots/GD.pm | 362 ----------------- lib/Plots/JSXGraph.pm | 685 +++++++++++++++++--------------- lib/Plots/Plot.pm | 139 ++++--- lib/Plots/Tikz.pm | 700 +++++++++++++++++++++++---------- macros/graph/plots.pl | 221 ++++++++--- 12 files changed, 1722 insertions(+), 1069 deletions(-) create mode 100644 htdocs/js/Plots/plots.js delete mode 100644 lib/Plots/GD.pm diff --git a/conf/pg_config.dist.yml b/conf/pg_config.dist.yml index 25295f05e4..4335415b49 100644 --- a/conf/pg_config.dist.yml +++ b/conf/pg_config.dist.yml @@ -236,7 +236,7 @@ modules: - [Multiple] - [PGrandom] - [Regression] - - ['Plots::Plot', 'Plots::Axes', 'Plots::Data', 'Plots::Tikz', 'Plots::JSXGraph', 'Plots::GD'] + - ['Plots::Plot', 'Plots::Axes', 'Plots::Data', 'Plots::Tikz', 'Plots::JSXGraph'] - [Select] - [Units] - [VectorField] diff --git a/htdocs/js/Plots/plots.js b/htdocs/js/Plots/plots.js new file mode 100644 index 0000000000..df2624b035 --- /dev/null +++ b/htdocs/js/Plots/plots.js @@ -0,0 +1,487 @@ +/* global JXG */ + +'use strict'; + +const PGplots = { + async plot(boardContainerId, plotContents, options) { + const drawBoard = (id) => { + const boundingBox = options.board?.boundingBox ?? [-5, 5, 5, -5]; + + // Disable highlighting for all elements. + JXG.Options.elements.highlight = false; + + // Adjust layers to match standard TikZ layers. The "axis" is bumped up a layer so it is above "axis + // ticks". The rest are on layer 3 by default, so they are moved up to main layer. The remaining layer + // settings should be okay for now. + JXG.Options.layer.axis = 3; + JXG.Options.layer.polygon = 5; + JXG.Options.layer.sector = 5; + JXG.Options.layer.angle = 5; + JXG.Options.layer.integral = 5; + + const board = JXG.JSXGraph.initBoard( + id, + JXG.merge( + { + title: options.board?.title ?? 'Graph', + boundingBox, + showCopyright: false, + axis: false, + drag: { enabled: false }, + showNavigation: options.board?.showNavigation ?? false, + pan: { enabled: options.board?.showNavigation ?? false }, + zoom: { enabled: options.board?.showNavigation ?? false } + }, + options.board?.overrideOptions ?? {} + ) + ); + + // The board now has its own clone of the options with the custom settings above which will apply for + // anything created on the board. So reset the JSXGraph defaults so that other JSXGraph images on the page + // don't get these settings. + JXG.Options.elements.highlight = true; + JXG.Options.layer.axis = 2; + JXG.Options.layer.polygon = 3; + JXG.Options.layer.sector = 3; + JXG.Options.layer.angle = 3; + JXG.Options.layer.integral = 3; + + const descriptionSpan = document.createElement('span'); + descriptionSpan.id = `${id}_description`; + descriptionSpan.classList.add('visually-hidden'); + descriptionSpan.textContent = options.ariaDescription ?? 'Generated graph'; + board.containerObj.after(descriptionSpan); + board.containerObj.setAttribute('aria-describedby', descriptionSpan.id); + + // Convert a decimal number into a fraction or mixed number. This is basically the JXG.toFraction method + // except that the "mixed" parameter is added, and it returns an improper fraction if mixed is false. + const toFraction = (x, useTeX, mixed, order) => { + const arr = JXG.Math.decToFraction(x, order); + + if (arr[1] === 0 && arr[2] === 0) { + return '0'; + } else { + let str = ''; + // Sign + if (arr[0] < 0) str += '-'; + if (arr[2] === 0) { + // Integer + str += arr[1]; + } else if (!(arr[2] === 1 && arr[3] === 1)) { + // Proper fraction + if (mixed) { + if (arr[1] !== 0) str += arr[1] + ' '; + if (useTeX) str += `\\frac{${arr[2]}}{${arr[3]}}`; + else str += `${arr[2]}/${arr[3]}`; + } else { + if (useTeX) str += `\\frac{${arr[3] * arr[1] + arr[2]}}{${arr[3]}}`; + else str += `${arr[3] * arr[1] + arr[2]}/${arr[3]}`; + } + } + return str; + } + }; + + // Override the default axis generateLabelText method so that 0 is displayed + // using MathJax if the axis is configured to show tick labels using MathJax. + const generateLabelText = function (tick, zero, value) { + if (JXG.exists(value)) return this.formatLabelText(value); + const distance = this.getDistanceFromZero(zero, tick); + return this.formatLabelText(Math.abs(distance) < JXG.Math.eps ? 0 : distance / this.visProp.scale); + }; + + const trimTrailingZeros = (value) => { + if (value.indexOf('.') > -1 && value.endsWith('0')) { + value = value.replace(/0+$/, ''); + // Remove the decimal if it is now at the end. + value = value.replace(/\.$/, ''); + } + return value; + }; + + // Override the formatLabelText method for the axes ticks so that + // better number formats can be used for tick labels. + const formatLabelText = function (value) { + let labelText; + + if (JXG.isNumber(value)) { + if (this.visProp.label.format === 'fraction' || this.visProp.label.format === 'mixed') { + labelText = toFraction( + value, + this.visProp.label.usemathjax, + this.visProp.label.format === 'mixed' + ); + } else if (this.visProp.label.format === 'scinot') { + const [mantissa, exponent] = value.toExponential(this.visProp.digits).toString().split('e'); + labelText = this.visProp.label.usemathjax + ? `${trimTrailingZeros(mantissa)}\\cdot 10^{${exponent}}` + : `${trimTrailingZeros(mantissa)} x 10^${exponent}`; + } else { + labelText = trimTrailingZeros(value.toFixed(this.visProp.digits).toString()); + } + } else { + labelText = value.toString(); + } + + if (this.visProp.scalesymbol.length > 0) { + if (labelText === '1') labelText = this.visProp.scalesymbol; + else if (labelText === '-1') labelText = `-${this.visProp.scalesymbol}`; + else if (labelText !== '0') labelText = labelText + this.visProp.scalesymbol; + } + + return this.visProp.label.usemathjax ? `\\(${labelText}\\)` : labelText; + }; + + board.suspendUpdate(); + + // This axis provides the vertical grid lines. + if (options.grid?.x) { + board.create( + 'axis', + [ + [options.xAxis?.min ?? -5, options.xAxis?.position ?? 0], + [options.xAxis?.max ?? 5, options.xAxis?.position ?? 0] + ], + JXG.merge( + { + anchor: + options.xAxis?.location === 'top' + ? 'left' + : options.xAxis?.location === 'bottom' || options.xAxis?.location === 'box' + ? 'right' + : 'right left', + position: + options.xAxis?.location === 'middle' + ? options.board?.showNavigation + ? 'sticky' + : 'static' + : 'fixed', + firstArrow: false, + lastArrow: false, + straightFirst: options.board?.showNavigation ? true : false, + straightLast: options.board?.showNavigation ? true : false, + highlight: false, + strokeOpacity: 0, + ticks: { + drawLabels: false, + drawZero: true, + majorHeight: -1, + minorHeight: -1, + strokeColor: options.grid.color ?? '#808080', + strokeOpacity: options.grid.opacity ?? 0.2, + insertTicks: false, + ticksDistance: options.xAxis?.ticks?.distance ?? 2, + scale: options.xAxis?.ticks?.scale ?? 1, + minorTicks: options.grid.x.minorGrids ? (options.xAxis?.ticks?.minorTicks ?? 3) : 0, + ignoreInfiniteTickEndings: false, + majorTickEndings: [ + !options.board?.showNavigation && boundingBox[1] > (options.yAxis?.max ?? 5) + ? 0 + : 1, + !options.board?.showNavigation && boundingBox[3] < (options.yAxis?.min ?? -5) + ? 0 + : 1 + ], + tickEndings: [ + !options.board?.showNavigation && boundingBox[1] > (options.yAxis?.max ?? 5) + ? 0 + : 1, + !options.board?.showNavigation && boundingBox[3] < (options.yAxis?.min ?? -5) + ? 0 + : 1 + ] + }, + withLabel: false + }, + options.grid.x.overrideOptions ?? {} + ) + ); + } + + // This axis provides the horizontal grid lines. + if (options.grid?.y) { + board.create( + 'axis', + [ + [options.yAxis?.position ?? 0, options.yAxis?.min ?? -5], + [options.yAxis?.position ?? 0, options.yAxis?.max ?? -5] + ], + JXG.merge( + { + anchor: + options.yAxis?.location === 'right' + ? 'right' + : options.yAxis?.location === 'left' || options.yAxis?.location === 'box' + ? 'left' + : 'right left', + position: + options.yAxis?.location === 'center' + ? options.board?.showNavigation + ? 'sticky' + : 'static' + : 'fixed', + firstArrow: false, + lastArrow: false, + straightFirst: options.board?.showNavigation ? true : false, + straightLast: options.board?.showNavigation ? true : false, + highlight: false, + strokeOpacity: 0, + ticks: { + drawLabels: false, + drawZero: true, + majorHeight: -1, + minorHeight: -1, + strokeColor: options.grid.color ?? '#808080', + strokeOpacity: options.grid.opacity ?? 0.2, + insertTicks: false, + ticksDistance: options.yAxis?.ticks?.distance ?? 2, + scale: options.yAxis?.ticks?.scale ?? 1, + minorTicks: options.grid.y.minorGrids ? (options.yAxis?.ticks?.minorTicks ?? 3) : 0, + ignoreInfiniteTickEndings: false, + majorTickEndings: [ + !options.board?.showNavigation && boundingBox[0] < (options.xAxis?.min ?? -5) + ? 0 + : 1, + !options.board?.showNavigation && boundingBox[2] > (options.xAxis?.max ?? 5) ? 0 : 1 + ], + tickEndings: [ + !options.board?.showNavigation && boundingBox[0] < (options.xAxis?.min ?? -5) + ? 0 + : 1, + !options.board?.showNavigation && boundingBox[2] > (options.xAxis?.max ?? 5) ? 0 : 1 + ] + }, + withLabel: 0 + }, + options.grid.y.overrideOptions ?? {} + ) + ); + } + + if (options.xAxis?.visible) { + const xAxis = board.create( + 'axis', + [ + [options.xAxis.min ?? -5, options.xAxis.position ?? 0], + [options.xAxis.max ?? 5, options.xAxis.position ?? 0] + ], + JXG.merge( + { + name: options.xAxis.name ?? '\\(x\\)', + anchor: + options.xAxis?.location === 'top' + ? 'left' + : options.xAxis?.location === 'bottom' || options.xAxis?.location === 'box' + ? 'right' + : 'right left', + position: + options.xAxis.location === 'middle' + ? options.board?.showNavigation + ? 'sticky' + : 'static' + : 'fixed', + firstArrow: options.axesArrowsBoth ? { size: 7 } : false, + lastArrow: { size: 7 }, + highlight: false, + straightFirst: options.board?.showNavigation ? true : false, + straightLast: options.board?.showNavigation ? true : false, + withLabel: options.xAxis.location === 'middle' ? true : false, + label: { + anchorX: 'right', + anchorY: 'middle', + highlight: false, + offset: [-5, -3], + position: '100% left', + useMathJax: true + }, + ticks: { + drawLabels: options.xAxis.ticks?.labels && options.xAxis.ticks?.show ? true : false, + drawZero: + options.board?.showNavigation || + !options.yAxis?.visible || + (options.yAxis.location === 'center' && (options.yAxis.position ?? 0) != 0) || + ((options.yAxis.location === 'left' || options.yAxis.location === 'box') && + (options.yAxis.min ?? -5) != 0) || + (options.yAxis.location === 'right' && (options.yAxis.max ?? 5) != 0) + ? true + : false, + insertTicks: false, + ticksDistance: options.xAxis.ticks?.distance ?? 2, + scale: options.xAxis.ticks?.scale ?? 1, + scaleSymbol: options.xAxis.ticks?.scaleSymbol ?? '', + minorTicks: options.xAxis.ticks?.minorTicks ?? 3, + majorHeight: options.xAxis.ticks?.show ? 8 : 0, + minorHeight: options.xAxis.ticks?.show ? 5 : 0, + strokeWidth: 1.5, + majorTickEndings: [1, options.xAxis.location === 'box' ? 0 : 1], + tickEndings: [1, options.xAxis.location === 'box' ? 0 : 1], + digits: options.xAxis.ticks?.labelDigits ?? 2, + label: { + anchorX: 'middle', + anchorY: options.xAxis.location === 'top' ? 'bottom' : 'top', + offset: options.xAxis.location === 'top' ? [0, 4] : [0, -4], + highlight: 0, + ...(options.mathJaxTickLabels ? { useMathJax: true, display: 'html' } : {}), + format: options.xAxis.ticks?.labelFormat ?? 'decimal' + } + } + }, + options.xAxis.overrideOptions ?? {} + ) + ); + xAxis.defaultTicks.generateLabelText = generateLabelText; + xAxis.defaultTicks.formatLabelText = formatLabelText; + + if (options.xAxis.location !== 'middle') { + board.create( + 'text', + [ + (xAxis.point1.X() + xAxis.point2.X()) / 2, + options.xAxis.location === 'top' ? board.getBoundingBox()[1] : board.getBoundingBox()[3], + options.xAxis.name ?? '\\(x\\)' + ], + { + anchorX: 'middle', + anchorY: options.xAxis.location === 'top' ? 'top' : 'bottom', + highlight: false, + color: 'black', + fixed: true, + useMathJax: true + } + ); + } + } + + if (options.yAxis?.visible) { + const yAxis = board.create( + 'axis', + [ + [options.yAxis.position ?? 0, options.yAxis.min ?? -5], + [options.yAxis.position ?? 0, options.yAxis.max ?? -5] + ], + JXG.merge( + { + name: options.yAxis.name ?? '\\(y\\)', + anchor: + options.yAxis?.location === 'right' + ? 'right' + : options.yAxis?.location === 'left' || options.yAxis?.location === 'box' + ? 'left' + : 'right left', + position: + options.yAxis.location === 'center' + ? options.board?.showNavigation + ? 'sticky' + : 'static' + : 'fixed', + firstArrow: options.axesArrowsBoth ? { size: 7 } : false, + lastArrow: { size: 7 }, + highlight: false, + straightFirst: options.board?.showNavigation ? true : false, + straightLast: options.board?.showNavigation ? true : false, + withLabel: options.yAxis.location === 'center' ? true : false, + label: { + anchorX: 'middle', + anchorY: 'top', + highlight: false, + distance: 1, + offset: [5, 1], + position: '100% right', + useMathJax: true + }, + ticks: { + drawLabels: options.yAxis.ticks?.labels && options.yAxis.ticks?.show ? true : false, + drawZero: + options.board?.showNavigation || + !options.xAxis?.visible || + (options.xAxis.location === 'middle' && (options.xAxis.position ?? 0) != 0) || + ((options.xAxis.location === 'bottom' || options.xAxis.location === 'box') && + (options.xAxis.min ?? -5) != 0) || + (options.xAxis.location === 'top' && (options.xAxis.max ?? 5) != 0) + ? true + : false, + insertTicks: false, + ticksDistance: options.yAxis.ticks?.distance ?? 2, + scale: options.yAxis.ticks?.scale ?? 1, + scaleSymbol: options.yAxis.ticks?.scaleSymbol ?? '', + minorTicks: options.yAxis.ticks?.minorTicks ?? 3, + majorHeight: options.yAxis.ticks?.show ? 8 : 0, + minorHeight: options.yAxis.ticks?.show ? 5 : 0, + strokeWidth: 1.5, + majorTickEndings: [options.yAxis.location === 'box' ? 0 : 1, 1], + tickEndings: [options.yAxis.location === 'box' ? 0 : 1, 1], + digits: options.yAxis.ticks?.labelDigits ?? 2, + label: { + anchorX: options.yAxis.location === 'right' ? 'left' : 'right', + anchorY: 'middle', + offset: options.yAxis.location === 'right' ? [6, 0] : [-6, 0], + highlight: false, + ...(options.mathJaxTickLabels ? { useMathJax: true, display: 'html' } : {}), + format: options.yAxis.ticks?.labelFormat ?? 'decimal' + } + } + }, + options.yAxis.overrideOptions ?? {} + ) + ); + yAxis.defaultTicks.generateLabelText = generateLabelText; + yAxis.defaultTicks.formatLabelText = formatLabelText; + + if (options.yAxis.location !== 'center') { + board.create( + 'text', + [ + options.yAxis.location === 'right' ? boundingBox[2] : boundingBox[0], + (yAxis.point1.Y() + yAxis.point2.Y()) / 2, + options.yAxis.name ?? '\\(y\\)' + ], + { + anchorX: 'middle', + anchorY: options.yAxis.location === 'right' ? 'bottom' : 'top', + rotate: 90, + highlight: 0, + color: 'black', + fixed: 1, + useMathJax: 1 + } + ); + } + } + + plotContents(board); + + board.unsuspendUpdate(); + + return board; + }; + + const container = document.getElementById(boardContainerId); + if (!container) return; + + const drawPromise = (id) => + new Promise((resolve) => { + if (container.offsetWidth === 0) { + setTimeout(async () => resolve(await drawPromise(id)), 100); + return; + } + resolve(drawBoard(id)); + }); + + await drawPromise(boardContainerId); + + let jsxBoard = null; + container.addEventListener('shown.imageview', async () => { + document + .getElementById(`magnified-${boardContainerId}`) + ?.classList.add(...Array.from(container.classList).filter((c) => c !== 'image-view-elt')); + jsxBoard = await drawPromise(`magnified-${boardContainerId}`); + }); + container.addEventListener('resized.imageview', () => { + jsxBoard?.resizeContainer(jsxBoard.containerObj.clientWidth, jsxBoard.containerObj.clientHeight, true); + }); + container.addEventListener('hidden.imageview', () => { + if (jsxBoard) JXG.JSXGraph.freeBoard(jsxBoard); + jsxBoard = null; + }); + } +}; diff --git a/htdocs/js/Plots/plots.scss b/htdocs/js/Plots/plots.scss index 19c3586878..116a1b3a76 100644 --- a/htdocs/js/Plots/plots.scss +++ b/htdocs/js/Plots/plots.scss @@ -1,4 +1,8 @@ .plots-jsxgraph { display: inline-block; - border-radius: 0px; + vertical-align: middle; + + &:not(.plots-jsxgraph-rounded) { + border-radius: 0px; + } } diff --git a/lib/Parser/Context/Default.pm b/lib/Parser/Context/Default.pm index c182fdcd9c..d66af413ba 100644 --- a/lib/Parser/Context/Default.pm +++ b/lib/Parser/Context/Default.pm @@ -435,6 +435,7 @@ $flags = { reduceConstantFunctions => 1, # 1 = compute function values of constants showExtraParens => 1, # 1 = add useful parens, 2 = make things painfully unambiguous stringifyNoBrackets => 0, # 1 = only use parentheses not brackets when stringifying + stringifyAbsAsFunction => 0, # 1 = abs(x) is stringified as abs(x) instead of |x| formatStudentAnswer => 'evaluated', # or 'parsed' or 'reduced' allowMissingOperands => 0, # 1 is used by Typeset context allowMissingFunctionInputs => 0, # 1 is used by Typeset context diff --git a/lib/Parser/Function/numeric.pm b/lib/Parser/Function/numeric.pm index d9540e4198..79edcc75a3 100644 --- a/lib/Parser/Function/numeric.pm +++ b/lib/Parser/Function/numeric.pm @@ -82,6 +82,8 @@ $Parser::reduce->{'ln(e^x)'} = 1; # sub string { my $self = shift; + return 'abs(' . $self->{params}[0]->string . ')' + if $self->{name} eq 'abs' && $self->context->flag('stringifyAbsAsFunction'); return '|' . $self->{params}[0]->string . '|' if $self->{name} eq 'abs'; return $self->SUPER::string(@_); } diff --git a/lib/Plots/Axes.pm b/lib/Plots/Axes.pm index f1da7cb454..9f9f094b5c 100644 --- a/lib/Plots/Axes.pm +++ b/lib/Plots/Axes.pm @@ -87,19 +87,52 @@ The maximum value the axis shows. Default is 5. 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. Note that this is only used if C is zero +and C is undefined. Default: 5. =item tick_delta -This is the distance between each major tick mark, starting from the origin. -If this is set to 0, this distance is set by using the number of ticks, C. -Default is 0. +This is the distance between each major tick mark, starting from the origin. If +this is set to 0 and C is not 0, then this distance is computed +to be the product of the C and the C, and if this is +set to 0 and C is undefined then this is computed to be the +difference between the C and C divided by the C. Default: 0 =item tick_labels This can be either 1 (show) or 0 (don't show) the labels for the major ticks. Default: 1 +=item tick_label_format + +This can be one of "decimal", "fraction", "multiple", or "scinot". If this is +"decimal", then tick labels will be displayed in decimal format. If this is +"fraction", then tick labels will be displayed as (improper) fractions. If this +is "mixed", then tick labels will be displayed as mixed numbers. If this is +"scinot", then tick labels will be displayed in scientific notation. Default: +"decimal" + +=item tick_label_digits + +The number of decimal places to round tick labels to when the +C is "decimal" or "scinot". Default: 2 + +=item tick_distance + +This is the unscaled distance between each major tick mark starting from the +origin when the axis is scaled by the C factor. If this is 0, then +this will be computed to be the C divided by the C. +Default: 0 + +=item tick_scale + +This is used in combination with the C above to calculate the +C. Default: 1 + +=item tick_scale_symbol + +This is appended to major tick labels. Default: '' + =item show_ticks This can be either 1 (show) or 0 (don't show) the tick lines. If ticks are @@ -115,8 +148,13 @@ 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. +This sets the number of minor ticks (and minor grid lines if minor_grids is 1) +per major tick. If this is set to 0, no minor ticks are shown. Default: 3 + +=item minor_grids + +If this is 1, then grid lines are shown at minor ticks, and if this is 0, then +grid lines are not shown at minor ticks. Default: 1 =item visible @@ -141,7 +179,13 @@ set to 'middle' or 'center'. Default is 0. =item jsx_options -A hash reference of options to be passed to the JSXGraph axis objects. +A hash reference of options to be passed to the JSXGraph axis object. + +=item jsx_grid_options + +A hash reference of options to be passed to the JSXGraph grid object. Note that +the grid is implemented as an axis with ticks the extend to infinity. So the +options are really JSXGraph axis options. =back @@ -185,6 +229,29 @@ Configures if the Tikz axis should be drawn on top of the graph (1) or below the 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 +Note that this setting is not honored for the JSXGraph image type. + +This is not the best way of ensuring that axis elements are not covered by a +fill. If this is used, then not only is the fill region placed behind the axis +and the grid, but all graphed elements are behind the axis and the grid which is +usually not desirable. A better way is to use the "axis background" C to +only place the fill on the "axis background" layer, and leave everything else on +top of the axis. + +=item axes_arrows_both + +Configures if arrows should be drawn in both directions (1) or only in the +positive direction (0) at the axes ends. In other words, this is a choice +between the convention that arrows are meant to indicate that the axes lines +continue forever, or the convention that arrows are meant to indicate the +positive direction of the axes only. Default: 0 + +=item mathjax_tick_labels + +If this is 1, then tick labels will be displayed using MathJax. If this is 0, +then ticks will be displayed as basic text. This only applies to the JSXGraph +output type. Default: 1 + =item jsx_navigation Either allow (1) or don't allow (0) the user to pan and zoom the view port of the @@ -196,6 +263,13 @@ of the graph that can be zoomed in or out. Default: 0 A hash reference of options to be passed to the JSXGraph board object. +=item tikz_options + +Additional options to be passed to the pgfplots axis definition. This should be +a single string. For example, to make longer and thicker x axis ticks use + + tikz_options => 'x tick style={line width=2pt},major tick length=0.6cm' + =back =cut @@ -206,18 +280,21 @@ use strict; use warnings; sub new { - my $class = shift; - my $self = bless { + my ($class, @options) = @_; + my $self = bless { xaxis => {}, yaxis => {}, styles => { - aria_label => 'Graph', - aria_description => 'Generated graph', - grid_color => 'gray', - grid_alpha => 40, - show_grid => 1, + aria_label => 'Graph', + aria_description => 'Generated graph', + grid_color => 'gray', + grid_alpha => 40, + show_grid => 1, + axis_on_top => 0, + axes_arrows_both => 0, + mathjax_tick_labels => 1, }, - @_ + @options }, $class; $self->xaxis($self->axis_defaults('x')); @@ -228,18 +305,24 @@ sub new { sub axis_defaults { my ($self, $axis) = @_; return ( - visible => 1, - min => -5, - max => 5, - label => $axis eq 'y' ? '\(y\)' : '\(x\)', - location => $axis eq 'y' ? 'center' : 'middle', - position => 0, - tick_labels => 1, - show_ticks => 1, - 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, + tick_labels => 1, + tick_label_format => 'decimal', + tick_label_digits => 2, + tick_distance => 0, + tick_scale => 1, + tick_scale_symbol => '', + show_ticks => 1, + tick_delta => 0, + tick_num => 5, + major => 1, + minor => 3, + minor_grids => 1 ); } @@ -256,8 +339,11 @@ sub axis { map { $self->{$axis}{$_} = $item->{$_}; } (keys %$item); return; } - # Deal with ticks individually since they may need to be generated. - return $item eq 'tick_delta' ? $self->tick_delta($self->{$axis}) : $self->{$axis}{$item}; + # Deal with the tick_delta and tick_distance individually since they may need to be computed. + return + $item eq 'tick_delta' ? $self->tick_delta($self->{$axis}) + : $item eq 'tick_distance' ? $self->tick_distance($self->{$axis}) + : $self->{$axis}{$item}; } sub xaxis { @@ -322,14 +408,28 @@ sub style { sub tick_delta { my ($self, $axis) = @_; 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}; + if ($axis->{tick_distance}) { + $axis->{tick_delta} = $axis->{tick_distance} * ($axis->{tick_scale} || 1); + } elsif ($axis->{tick_num}) { + $axis->{tick_delta} = ($axis->{max} - $axis->{min}) / $axis->{tick_num}; + } else { + $axis->{tick_delta} = 2; + } return $axis->{tick_delta}; } +sub tick_distance { + my ($self, $axis) = @_; + return $axis->{tick_distance} if $axis->{tick_distance}; + my $tick_delta = $self->tick_delta($axis); + $axis->{tick_distance} = $axis->{tick_delta} / ($axis->{tick_scale} || 1); + return $axis->{tick_distance}; +} + sub grid { my $self = shift; - return $self->get('xmajor', 'xminor', 'xtick_delta', 'ymajor', 'yminor', 'ytick_delta'); + return $self->get('xmajor', 'xminor_grids', 'xminor', 'xtick_delta', 'ymajor', 'yminor_grids', 'yminor', + 'ytick_delta'); } sub bounds { diff --git a/lib/Plots/Data.pm b/lib/Plots/Data.pm index 2f6a07c383..3c910ce1bf 100644 --- a/lib/Plots/Data.pm +++ b/lib/Plots/Data.pm @@ -217,13 +217,11 @@ sub set_function { }; 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}; + $f->{$key} = delete $options{$key}; } for my $key ('var', 'min', 'max', 'steps') { next unless defined $options{$key}; - $f->{"x$key"} = $options{$key}; - delete $options{$key}; + $f->{"x$key"} = delete $options{$key}; } return unless $f->{Fy}; @@ -257,13 +255,15 @@ sub function_string { # Ensure -x^2 gets print as -(x^2), since JavaScript finds this ambiguous. my $extraParens = $formula->context->flag('showExtraParens'); - my $format = $formula->context->{format}{number}; - $formula->context->flags->set(showExtraParens => 2); + # Ensure that abs(x) is stringified as abs(x) instead of |x|. + my $stringifyAbsAsFunction = $formula->context->flag('stringifyAbsAsFunction'); + my $format = $formula->context->{format}{number}; + $formula->context->flags->set(showExtraParens => 2, stringifyAbsAsFunction => 1); $formula->context->{format}{number} = "%f#"; # Get no bracket string for $formula my $func = $formula . ""; $func =~ s/\s//g; - $formula->context->flags->set(showExtraParens => $extraParens); + $formula->context->flags->set(showExtraParens => $extraParens, stringifyAbsAsFunction => $stringifyAbsAsFunction); $formula->context->{format}{number} = $format; my %tokens; @@ -318,8 +318,8 @@ sub function_string { ceil => 'Math.ceil', sign => 'Math.sign', int => 'Math.trunc', - log => 'Math.ln', - ln => 'Math.ln', + log => 'Math.log', + ln => 'Math.log', cos => 'Math.cos', sin => 'Math.sin', tan => 'Math.tan', @@ -334,11 +334,11 @@ sub function_string { sinh => 'Math.sinh', tanh => 'Math.tanh', acosh => 'Math.acosh', - arccosh => 'Math.arccosh', + arccosh => 'Math.acosh', asinh => 'Math.asinh', arcsinh => 'Math.asinh', atanh => 'Math.atanh', - arctanh => 'Math.arctanh', + arctanh => 'Math.atanh', min => 'Math.min', max => 'Math.max', random => 'Math.random', diff --git a/lib/Plots/GD.pm b/lib/Plots/GD.pm deleted file mode 100644 index 800374a002..0000000000 --- a/lib/Plots/GD.pm +++ /dev/null @@ -1,362 +0,0 @@ - -=head1 DESCRIPTION - -This is the code that takes a C and creates the GD code for generation. - -See L for more details. - -=cut - -package Plots::GD; - -use GD; - -use strict; -use warnings; - -sub new { - my ($class, $plots) = @_; - return bless { - image => '', - plots => $plots, - position => [ 0, 0 ], - colors => {}, - image => GD::Image->new($plots->size) - }, $class; -} - -sub plots { - my $self = shift; - return $self->{plots}; -} - -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->plots->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 $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 $plots = $self->plots; - my ($ymin, $ymax) = ($plots->axes->yaxis('min'), $plots->axes->yaxis('max')); - (undef, my $height) = $plots->size; - return int(($ymax - $y) * $height / ($ymax - $ymin)); -} - -sub moveTo { - my ($self, $x, $y) = @_; - $x = $self->im_x($x); - $y = $self->im_y($y); - $self->position($x, $y); - return; -} - -sub lineTo { - my ($self, $x, $y, $color, $width, $dashed) = @_; - $color = 'default_color' unless defined($color); - $color = $self->color($color); - $width = 1 unless defined($width); - $dashed = 0 unless defined($dashed); - $x = $self->im_x($x); - $y = $self->im_y($y); - - $self->im->setThickness($width); - if ($dashed =~ /dash/) { - my @dashing = ($color) x (4 * $width * $width); - my @spacing = (GD::gdTransparent) x (3 * $width * $width); - $self->im->setStyle(@dashing, @spacing); - $self->im->line($self->position, $x, $y, GD::gdStyled); - } elsif ($dashed =~ /dot/) { - my @dashing = ($color) x (1 * $width * $width); - my @spacing = (GD::gdTransparent) x (2 * $width * $width); - $self->im->setStyle(@dashing, @spacing); - $self->im->line($self->position, $x, $y, GD::gdStyled); - } else { - $self->im->line($self->position, $x, $y, $color); - } - $self->im->setThickness(1); - $self->position($x, $y); - return; -} - -# Draw functions / lines / arrows -sub draw_data { - my ($self, $pass) = @_; - my $plots = $self->plots; - $pass = 0 unless $pass; - for my $data ($plots->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 '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); - } 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 '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); - } elsif ($end eq 'arrow') { - $self->draw_arrow_head($data->x($n - 1), $data->y($n - 1), $data->x($n), $data->y($n), $color, $width); - } - } - } - return; -} - -# Label helpers -sub get_gd_font { - my ($self, $font) = @_; - if ($font eq 'tiny') { return GD::gdTinyFont; } - elsif ($font eq 'small') { return GD::gdSmallFont; } - elsif ($font eq 'large') { return GD::gdLargeFont; } - elsif ($font eq 'giant') { return GD::gdGiantFont; } - return GD::gdMediumBoldFont; -} - -sub label_offset { - my ($self, $loc, $str, $fontsize) = @_; - my $offset = 0; - # Add an additional 2px offset for the edges 'right', 'bottom', 'left', and 'top'. - if ($loc eq 'right') { $offset -= length($str) * $fontsize + 2; } - elsif ($loc eq 'bottom') { $offset -= $fontsize + 2; } - elsif ($loc eq 'center') { $offset -= length($str) * $fontsize / 2; } - elsif ($loc eq 'middle') { $offset -= $fontsize / 2; } - else { $offset = 2; } # Both 'left' and 'top'. - return $offset; -} - -sub draw_label { - my ($self, $str, $x, $y, %options) = @_; - my $font = $self->get_gd_font($options{fontsize} || 'medium'); - my $color = $self->color($options{color} || 'default_color'); - my $xoff = $self->label_offset($options{h_align} || 'center', $str, $font->width); - my $yoff = $self->label_offset($options{v_align} || 'middle', $str, $font->height); - - if ($options{orientation} && $options{orientation} eq 'vertical') { - $self->im->stringUp($font, $self->im_x($x) + $xoff, $self->im_y($y) + $yoff, $str, $color); - } else { - $self->im->string($font, $self->im_x($x) + $xoff, $self->im_y($y) + $yoff, $str, $color); - } - return; -} - -sub draw_arrow_head { - my ($self, $x1, $y1, $x2, $y2, $color, $w) = @_; - return unless @_ > 4; - $color = $self->color($color || 'default_color'); - $w = 1 unless $w; - ($x1, $y1) = ($self->im_x($x1), $self->im_y($y1)); - ($x2, $y2) = ($self->im_x($x2), $self->im_y($y2)); - - my $dx = $x2 - $x1; - my $dy = $y2 - $y1; - my $len = sqrt($dx * $dx + $dy * $dy); - my $ux = $dx / $len; # Unit vector in direction of arrow. - my $uy = $dy / $len; - my $px = -1 * $uy; # Unit vector perpendicular to arrow. - my $py = $ux; - my $hbx = $x2 - 7 * $w * $ux; - my $hby = $y2 - 7 * $w * $uy; - my $head = GD::Polygon->new; - $head->addPt($x2, $y2); - $head->addPt($hbx + 3 * $w * $px, $hby + 3 * $w * $py); - $head->addPt($hbx - 3 * $w * $px, $hby - 3 * $w * $py); - $self->im->setThickness($w); - $self->im->filledPolygon($head, $color); - $self->im->setThickness(1); - return; -} - -sub draw_circle_stamp { - my ($self, $x, $y, $r, $color, $filled) = @_; - my $d = $r ? 2 * $r : 8; - $color = $self->color($color || 'default_color'); - $self->im->filledArc($self->im_x($x), $self->im_y($y), $d, $d, 0, 360, $self->color('white')); - $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 $plots = $self->plots; - my $axes = $plots->axes; - my $grid = $axes->grid; - my ($width, $height) = $plots->size; - - # Initialize image - $self->im->interlaced('true'); - $self->im->fill(1, 1, $self->color('white')); - - # Plot data first, then fill in regions before adding axes, grid, etc. - $self->draw_data(1); - - # Fill regions - 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'))); - } - - # 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 $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); - 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 $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); - 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 $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 ($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'; - 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' - ); - 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 && $axes->yaxis('visible'); - $x -= $dx; - } - } - 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 - ); - - 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 && $axes->xaxis('visible'); - $y -= $dy; - } - } - - # Draw data a second time to cleanup any issues with the grid and axes. - $self->draw_data(2); - - # Print Labels - 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 ($plots->data('stamp')) { - my $symbol = $stamp->style('symbol'); - my $color = $stamp->style('color'); - my $r = $stamp->style('radius') || 4; - 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); - } - } - - # Put a black frame around the picture - $self->im->rectangle(0, 0, $width - 1, $height - 1, $self->color('black')); - - return $plots->ext eq 'gif' ? $self->im->gif : $self->im->png; -} - -1; diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index 6b531d8612..af1248a664 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -17,9 +17,10 @@ sub new { $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'); + $plots->add_js_file('node_modules/jsxgraph/distrib/jsxgraphcore.js', { defer => undef }); + $plots->add_js_file('js/Plots/plots.js', { defer => undef }); - return bless { plots => $plots }, $class; + return bless { plots => $plots, names => { xaxis => 1 } }, $class; } sub plots { @@ -29,57 +30,130 @@ sub plots { sub HTML { my $self = shift; - 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"'; - my $details = $self->plots->{description_details} =~ s/LONG-DESCRIPTION-ID/${name}_details/r; - my $aria_details = $details ? qq! aria-details="${name}_details"! : ''; - my $divs = qq!
plots; + my ($width, $height) = $plots->size; + + my $imageviewClass = $plots->axes->style('jsx_navigation') ? '' : ' image-view-elt'; + my $tabindex = $plots->axes->style('jsx_navigation') ? '' : ' tabindex="0"'; + my $roundedCornersClass = $plots->{rounded_corners} ? ' plots-jsxgraph-rounded' : ''; + my $details = $plots->{description_details} =~ s/LONG-DESCRIPTION-ID/$self->{name}_details/r; + my $aria_details = $details ? qq! aria-details="$self->{name}_details"! : ''; + + my $divs = + qq!
!; - $divs = qq!
$divs$details
! if ($details); + $divs = qq!
$divs$details
! if $details; + + my $axes = $plots->axes; + 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 $grid = $axes->grid; + my ($xmin, $ymin, $xmax, $ymax) = $axes->bounds; + + my ($xvisible, $yvisible) = ($axes->xaxis('visible'), $axes->yaxis('visible')); + + my $options = {}; + + $options->{ariaDescription} = $axes->style('aria_description') if defined $axes->style('aria_description'); + + $options->{board}{title} = $axes->style('aria_label'); + $options->{board}{showNavigation} = $axes->style('jsx_navigation') ? 1 : 0; + $options->{board}{overrideOptions} = $axes->style('jsx_options') if $axes->style('jsx_options'); + + # Set the bounding box. Add padding for the axes at the edge of graph if needed. + $options->{board}{boundingBox} = [ + $xmin - ( + $yvisible + && ($yaxis_loc eq 'left' || $yaxis_loc eq 'box' || $xmin == $yaxis_pos) ? 0.11 * ($xmax - $xmin) : 0 + ), + $ymax + ($xvisible && ($xaxis_loc eq 'top' || $ymax == $xaxis_pos) ? 0.11 * ($ymax - $ymin) : 0), + $xmax + ($yvisible && ($yaxis_loc eq 'right' || $xmax == $yaxis_pos) ? 0.11 * ($xmax - $xmin) : 0), + $ymin - ( + $xvisible + && ($xaxis_loc eq 'bottom' || $xaxis_loc eq 'box' || $ymin == $xaxis_pos) ? 0.11 * ($ymax - $ymin) : 0 + ) + ]; + + $options->{xAxis}{visible} = $xvisible; + if ($xvisible || ($show_grid && $grid->{xmajor})) { + ($options->{xAxis}{min}, $options->{xAxis}{max}) = ($xmin, $xmax); + $options->{xAxis}{position} = $xaxis_pos; + $options->{xAxis}{location} = $xaxis_loc; + $options->{xAxis}{ticks}{scale} = $axes->xaxis('tick_scale'); + $options->{xAxis}{ticks}{distance} = $axes->xaxis('tick_distance'); + $options->{xAxis}{ticks}{minorTicks} = $grid->{xminor}; + } + + $options->{yAxis}{visible} = $yvisible; + if ($yvisible || ($show_grid && $grid->{ymajor})) { + ($options->{yAxis}{min}, $options->{yAxis}{max}) = ($ymin, $ymax); + $options->{yAxis}{position} = $yaxis_pos; + $options->{yAxis}{location} = $yaxis_loc; + $options->{yAxis}{ticks}{scale} = $axes->yaxis('tick_scale'); + $options->{yAxis}{ticks}{distance} = $axes->yaxis('tick_distance'); + $options->{yAxis}{ticks}{minorTicks} = $grid->{yminor}; + } + + if ($show_grid) { + if ($grid->{xmajor} || $grid->{ymajor}) { + $options->{grid}{color} = $self->get_color($axes->style('grid_color')); + $options->{grid}{opacity} = $axes->style('grid_alpha') / 200; + } + + if ($grid->{xmajor}) { + $options->{grid}{x}{minorGrids} = $grid->{xminor_grids}; + $options->{grid}{x}{overrideOptions} = $axes->xaxis('jsx_grid_options') if $axes->xaxis('jsx_grid_options'); + } + + if ($grid->{ymajor}) { + $options->{grid}{y}{minorGrids} = $grid->{yminor_grids}; + $options->{grid}{y}{overrideOptions} = $axes->yaxis('jsx_grid_options') if $axes->yaxis('jsx_grid_options'); + } + } + + if ($xvisible || $yvisible) { + $options->{mathJaxTickLabels} = $axes->style('mathjax_tick_labels'); + $options->{axesArrowsBoth} = $axes->style('axes_arrows_both'); + } + + if ($xvisible) { + $options->{xAxis}{name} = $axes->xaxis('label'); + $options->{xAxis}{ticks}{show} = $axes->xaxis('show_ticks'); + $options->{xAxis}{ticks}{labels} = $axes->xaxis('tick_labels'); + $options->{xAxis}{ticks}{labelFormat} = $axes->xaxis('tick_label_format'); + $options->{xAxis}{ticks}{labelDigits} = $axes->xaxis('tick_label_digits'); + $options->{xAxis}{ticks}{scaleSymbol} = $axes->xaxis('tick_scale_symbol'); + $options->{xAxis}{overrideOptions} = $axes->xaxis('jsx_options') if $axes->xaxis('jsx_options'); + } + if ($yvisible) { + $options->{yAxis}{name} = $axes->yaxis('label'); + $options->{yAxis}{ticks}{show} = $axes->yaxis('show_ticks'); + $options->{yAxis}{ticks}{labels} = $axes->yaxis('tick_labels'); + $options->{yAxis}{ticks}{labelFormat} = $axes->yaxis('tick_label_format'); + $options->{yAxis}{ticks}{labelDigits} = $axes->yaxis('tick_label_digits'); + $options->{yAxis}{ticks}{scaleSymbol} = $axes->yaxis('tick_scale_symbol'); + $options->{yAxis}{overrideOptions} = $axes->yaxis('jsx_options') if $axes->yaxis('jsx_options'); + } + + $self->{JS} //= ''; + $plots->{extra_js_code} //= ''; return <<~ "END_HTML"; $divs END_HTML @@ -88,7 +162,9 @@ sub HTML { sub get_color { my ($self, $color) = @_; $color = 'default_color' unless $color; - return sprintf("#%02x%02x%02x", @{ $self->plots->colors($color) }); + my $colorParts = $self->plots->colors($color); + return $color unless ref $colorParts eq 'ARRAY'; # Try to use the color by name if it wasn't defined. + return sprintf("#%02x%02x%02x", @$colorParts); } sub get_linestyle { @@ -107,39 +183,95 @@ sub get_linestyle { || 0; } +# Translate pgfplots layers to JSXGraph layers. +# FIXME: JSXGraph layers work rather differently than pgfplots layers. So this is a bit fuzzy, and may need adjustment. +# The layers chosen are as close as possible to the layers that JSXGraph uses by default, although "pre main" and "main" +# don't really have an equivalent. See https://jsxgraph.uni-bayreuth.de/docs/symbols/JXG.Options.html#layer. +# This also does not honor the "axis_on_top" setting. +sub get_layer { + my ($self, $data, $useFillLayer) = @_; + my $layer = $data->style($useFillLayer ? 'fill_layer' : 'layer'); + return unless $layer; + return { + 'axis background' => 0, + 'axis grid' => 1, + 'axis ticks' => 2, + 'axis lines' => 3, + 'pre main' => 4, + 'main' => 5, + 'axis tick labels' => 9, + 'axis descriptions' => 9, + 'axis foreground' => 10 + }->{$layer} // undef; +} + sub get_options { my ($self, $data, %extra_options) = @_; - my $options = Mojo::JSON::encode_json({ - highlight => 0, - strokeColor => $self->get_color($data->style('color')), - strokeWidth => $data->style('width'), - $data->style('start_mark') eq 'arrow' - ? (firstArrow => { type => 4, size => $data->style('arrow_size') || 8 }) - : (), - $data->style('end_mark') eq 'arrow' ? (lastArrow => { type => 4, size => $data->style('arrow_size') || 8 }) - : (), - $data->style('fill') eq 'self' - ? ( - fillColor => $self->get_color($data->style('fill_color') || $data->style('color')), - fillOpacity => $data->style('fill_opacity') - || 0.5 - ) - : (), - dash => $self->get_linestyle($data), - %extra_options, - }); - return $data->style('jsx_options') - ? "JXG.merge($options, " . Mojo::JSON::encode_json($data->style('jsx_options')) . ')' - : $options; + + my $fill = $data->style('fill') || 'none'; + my $drawLayer = $self->get_layer($data); + my $fillLayer = $self->get_layer($data, 1) // $drawLayer; + + my $drawFillSeparate = + $fill eq 'self' + && $data->style('linestyle') ne 'none' + && defined $fillLayer + && (!defined $drawLayer || $drawLayer != $fillLayer); + + my (%drawOptions, %fillOptions); + + if ($data->style('linestyle') ne 'none') { + $drawOptions{layer} = $drawLayer if defined $drawLayer; + $drawOptions{dash} = $self->get_linestyle($data); + $drawOptions{strokeColor} = $self->get_color($data->style('color')); + $drawOptions{strokeWidth} = $data->style('width'); + $drawOptions{firstArrow} = { type => 2, size => $data->style('arrow_size') || 8 } + if $data->style('start_mark') eq 'arrow'; + $drawOptions{lastArrow} = { type => 2, size => $data->style('arrow_size') || 8 } + if $data->style('end_mark') eq 'arrow'; + } + + if ($drawFillSeparate) { + $fillOptions{strokeWidth} = 0; + $fillOptions{layer} = $fillLayer; + $fillOptions{fillColor} = $self->get_color($data->style('fill_color') || $data->style('color')); + $fillOptions{fillOpacity} = $data->style('fill_opacity') || 0.5; + @fillOptions{ keys %extra_options } = values %extra_options; + } elsif ($fill eq 'self') { + if (!%drawOptions) { + $drawOptions{strokeWidth} = 0; + $drawOptions{layer} = $fillLayer if defined $fillLayer; + } + $drawOptions{fillColor} = $self->get_color($data->style('fill_color') || $data->style('color')); + $drawOptions{fillOpacity} = $data->style('fill_opacity') || 0.5; + } elsif ($data->style('name') && $data->style('linestyle') eq 'none') { + # This forces the curve to be drawn invisibly if it has been named, but the linestyle is 'none'. + $drawOptions{strokeWidth} = 0; + } + + @drawOptions{ keys %extra_options } = values %extra_options if %drawOptions; + + my $drawOptions = %drawOptions ? Mojo::JSON::encode_json(\%drawOptions) : ''; + my $fillOptions = $drawFillSeparate ? Mojo::JSON::encode_json(\%fillOptions) : ''; + return ( + $drawOptions && $data->style('jsx_options') + ? "JXG.merge($drawOptions, " . Mojo::JSON::encode_json($data->style('jsx_options')) . ')' + : $drawOptions, + $fillOptions && $data->style('jsx_options') + ? "JXG.merge($fillOptions, " . Mojo::JSON::encode_json($data->style('jsx_options')) . ')' + : $fillOptions + ); } sub add_curve { my ($self, $data) = @_; - return if $data->style('linestyle') eq 'none'; - my $curve_name = $data->style('name'); - my $fill = $data->style('fill') || 'none'; - my $plotOptions = $self->get_options($data, $data->style('polar') ? (curveType => 'polar') : ()); + my $curve_name = $data->style('name'); + warn 'Duplicate plot name detected. This will most likely cause issues. Make sure that all names used are unique.' + if $curve_name && $self->{names}{$curve_name}; + $self->{names}{$curve_name} = 1 if $curve_name; + + my ($plotOptions, $fillOptions) = $self->get_options($data, $data->style('polar') ? (curveType => 'polar') : ()); my $type = 'curve'; my $data_points; @@ -172,68 +304,83 @@ sub add_curve { $data_points = '[[' . join(',', $data->x) . '],[' . join(',', $data->y) . ']]'; } - $self->{JS} .= "const curve_${curve_name} = " if $curve_name; - $self->{JS} .= "board.create('$type', $data_points, $plotOptions);"; - $self->add_point($data, $data->get_start_point, $data->style('width'), $data->style('start_mark')) - if $data->style('start_mark') =~ /circle/; - $self->add_point($data, $data->get_end_point, $data->style('width'), $data->style('end_mark')) - if $data->style('end_mark') =~ /circle/; - + $self->{JS} .= "const curve_${curve_name} = " if $curve_name; + $self->{JS} .= "board.create('$type', $data_points, $plotOptions);" if $plotOptions; + $self->{JS} .= "board.create('$type', $data_points, $fillOptions);" if $fillOptions; + $self->add_point( + $data, $data->get_start_point, + 1.1 * ($data->style('width') || 2), + $data->style('width') || 2, + $data->style('start_mark') + ) if $data->style('linestyle') ne 'none' && $data->style('start_mark') =~ /circle/; + $self->add_point( + $data, $data->get_end_point, + 1.1 * ($data->style('width') || 2), + $data->style('width') || 2, + $data->style('end_mark') + ) if $data->style('linestyle') ne 'none' && $data->style('end_mark') =~ /circle/; + + my $fill = $data->style('fill') || 'none'; if ($fill ne 'none' && $fill ne 'self') { - if ($curve_name) { - 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({ - strokeWidth => 0, - fillColor => $self->get_color($data->style('fill_color') || $data->style('color')), - fillOpacity => $data->style('fill_opacity') || 0.5, - highlight => 0, - }); - - if ($fill eq 'xaxis') { - $self->{JSend} .= - "const fill_${curve_name} = board.create('curve', [[], []], $fillOptions);" - . "fill_${curve_name}.updateDataArray = function () {" - . "const points = curve_${curve_name}.points"; - if ($fill_min ne '' && $fill_max ne '') { - $self->{JSend} .= - ".filter(p => {" - . "return p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false" . "})"; + if ($self->{names}{$fill}) { + if ($curve_name) { + my $fill_min = $data->str_to_real($data->style('fill_min')); + my $fill_max = $data->str_to_real($data->style('fill_max')); + my $fill_layer = $self->get_layer($data, 1) // $self->get_layer($data); + my $fillOptions = Mojo::JSON::encode_json({ + strokeWidth => 0, + fillColor => $self->get_color($data->style('fill_color') || $data->style('color')), + fillOpacity => $data->style('fill_opacity') || 0.5, + defined $fill_layer ? (layer => $fill_layer) : (), + }); + + if ($fill eq 'xaxis') { + $self->{JS} .= + "const fill_${curve_name} = board.create('curve', [[], []], $fillOptions);" + . "fill_${curve_name}.updateDataArray = function () {" + . "const points = curve_${curve_name}.points"; + if ($fill_min ne '' && $fill_max ne '') { + $self->{JS} .= + ".filter(p => {" + . "return p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false" . "})"; + } + $self->{JS} .= + ";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.update();"; + } else { + $self->{JS} .= + "const fill_${curve_name} = board.create('curve', [[], []], $fillOptions);" + . "fill_${curve_name}.updateDataArray = function () {" + . "const points1 = curve_${curve_name}.points"; + if ($fill_min ne '' && $fill_max ne '') { + $self->{JS} .= + ".filter(p => {" + . "return p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false" . "})"; + } + $self->{JS} .= ";const points2 = curve_${fill}.points"; + if ($fill_min ne '' && $fill_max ne '') { + $self->{JS} .= + ".filter(p => {" + . "return p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false" . "})"; + } + $self->{JS} .= + ";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.update();"; } - $self->{JSend} .= - ";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.update();"; } else { - $self->{JSend} .= - "const fill_${curve_name} = board.create('curve', [[], []], $fillOptions);" - . "fill_${curve_name}.updateDataArray = function () {" - . "const points1 = curve_${curve_name}.points"; - if ($fill_min ne '' && $fill_max ne '') { - $self->{JSend} .= - ".filter(p => {" - . "return p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false" . "})"; - } - $self->{JSend} .= ";const points2 = curve_${fill}.points"; - if ($fill_min ne '' && $fill_max ne '') { - $self->{JSend} .= - ".filter(p => {" - . "return p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false" . "})"; - } - $self->{JSend} .= - ";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.update();"; + warn q{Unable to create fill. Missing 'name' attribute.}; } } else { - warn "Unable to create fill. Missing 'name' attribute."; + warn q{Unable to fill between curves. Other graph has not yet been drawn.}; } } return; @@ -241,13 +388,15 @@ sub add_curve { sub add_multipath { my ($self, $data) = @_; - return if $data->style('linestyle') eq 'none'; - my @paths = @{ $data->{paths} }; - my $n = scalar(@paths); - my $var = $data->{function}{var}; - my $curve_name = $data->style('name'); - my $plotOptions = $self->get_options($data); + my @paths = @{ $data->{paths} }; + my $n = scalar(@paths); + my $var = $data->{function}{var}; + my $curve_name = $data->style('name'); + warn 'Duplicate plot name detected. This will most likely cause issues. Make sure that all names used are unique.' + if $curve_name && $self->{names}{$curve_name}; + $self->{names}{$curve_name} = 1 if $curve_name; + my ($plotOptions, $fillOptions) = $self->get_options($data); my $jsFunctionx = 'function (x){'; my $jsFunctiony = 'function (x){'; @@ -269,13 +418,14 @@ sub add_multipath { $jsFunctionx .= 'return 0;}'; $jsFunctiony .= 'return 0;}'; - $self->{JS} .= "const curve_${curve_name} = " if $curve_name; - $self->{JS} .= "board.create('curve', [$jsFunctionx, $jsFunctiony, 0, 1], $plotOptions);"; + $self->{JS} .= "const curve_${curve_name} = " if $curve_name; + $self->{JS} .= "board.create('curve', [$jsFunctionx, $jsFunctiony, 0, 1], $plotOptions);" if $plotOptions; + $self->{JS} .= "board.create('curve', [$jsFunctionx, $jsFunctiony, 0, 1], $fillOptions);" if $fillOptions; return; } sub add_point { - my ($self, $data, $x, $y, $size, $mark) = @_; + my ($self, $data, $x, $y, $size, $strokeWidth, $mark) = @_; my $color = $self->get_color($data->style('color')); my $fill = $color; @@ -318,7 +468,7 @@ sub add_point { strokeColor => $color, fillColor => $fill, size => $size, - highlight => 0, + strokeWidth => $strokeWidth, showInfoBox => 0, }); $pointOptions = "JXG.merge($pointOptions, " . Mojo::JSON::encode_json($data->style('jsx_options')) . ')' @@ -337,29 +487,60 @@ sub add_points { $data->gen_data if $data->name eq 'function'; for (0 .. $data->size - 1) { - $self->add_point($data, $data->x($_), $data->y($_), $data->style('mark_size') || $data->style('width'), $mark); + $self->add_point( + $data, $data->x($_), $data->y($_), + $data->style('mark_size') || 2, + $data->style('width') || 2, $mark + ); } return; } +sub add_vectorfield { + my ($self, $data) = @_; + my $f = $data->{function}; + my $xfunction = $data->function_string($f->{Fx}, 'js', $f->{xvar}, $f->{yvar}); + my $yfunction = $data->function_string($f->{Fy}, 'js', $f->{xvar}, $f->{yvar}); + + if ($xfunction ne '' && $yfunction ne '') { + my ($options) = $self->get_options( + $data, + scale => $data->style('scale') || 1, + ($data->style('slopefield') ? (arrowhead => { enabled => 0 }) : ()), + ); + $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} .= "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.'; + } +} + sub add_circle { my ($self, $data) = @_; - my $x = $data->x(0); - my $y = $data->y(0); - my $r = $data->style('radius'); - my $linestyle = $self->get_linestyle($data); - my $circleOptions = $self->get_options($data); + my $x = $data->x(0); + my $y = $data->y(0); + my $r = $data->style('radius'); + my ($circleOptions, $fillOptions) = $self->get_options($data); $self->{JS} .= "board.create('circle', [[$x, $y], $r], $circleOptions);"; + $self->{JS} .= "board.create('circle', [[$x, $y], $r], $fillOptions);" if $fillOptions; return; } sub add_arc { - my ($self, $data) = @_; - my ($x1, $y1) = ($data->x(0), $data->y(0)); - my ($x2, $y2) = ($data->x(1), $data->y(1)); - my ($x3, $y3) = ($data->x(2), $data->y(2)); - my $arcOptions = $self->get_options( + my ($self, $data) = @_; + my ($x1, $y1) = ($data->x(0), $data->y(0)); + my ($x2, $y2) = ($data->x(1), $data->y(1)); + my ($x3, $y3) = ($data->x(2), $data->y(2)); + my ($arcOptions, $fillOptions) = $self->get_options( $data, anglePoint => { visible => 0 }, center => { visible => 0 }, @@ -367,195 +548,34 @@ sub add_arc { ); $self->{JS} .= "board.create('arc', [[$x1, $y1], [$x2, $y2], [$x3, $y3]], $arcOptions);"; + $self->{JS} .= "board.create('arc', [[$x1, $y1], [$x2, $y2], [$x3, $y3]], $fillOptions);" if $fillOptions; return; } -sub init_graph { - my $self = shift; - my $plots = $self->plots; - my $axes = $plots->axes; - 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; - $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 = - $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 = - $allow_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; - $ymax += 0.11 * ($ymax - $ymin) if $xaxis_loc eq 'top' || $ymax == $xaxis_pos; - - my $JSXOptions = Mojo::JSON::encode_json({ - title => $axes->style('aria_label'), - boundingBox => [ $xmin, $ymax, $xmax, $ymin ], - axis => 0, - showNavigation => $allow_navigation, - pan => { enabled => $allow_navigation }, - zoom => { enabled => $allow_navigation }, - showCopyright => 0, - drag => { enabled => 0 }, - }); - $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' ? ($allow_navigation ? 'sticky' : 'static') : '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 => $allow_navigation, - straightLast => $allow_navigation, - 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, - drawZero => $x_draw_zero, - strokeColor => $self->get_color($axes->style('grid_color')), - strokeOpacity => $axes->style('grid_alpha') / 200, - insertTicks => 0, - ticksDistance => $axes->xaxis('tick_delta'), - majorHeight => $axes->xaxis('show_ticks') ? ($show_grid && $axes->xaxis('major') ? -1 : 10) : 0, - minorTicks => $axes->xaxis('minor'), - minorHeight => $axes->xaxis('show_ticks') ? ($show_grid && $axes->xaxis('major') ? -1 : 7) : 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' ? ($allow_navigation ? 'sticky' : 'static') : 'fixed', - anchor => $yaxis_loc eq 'center' ? 'right left' : $yaxis_loc, - visible => $axes->yaxis('visible') ? 1 : 0, - highlight => 0, - firstArrow => 0, - lastArrow => { size => 7 }, - straightFirst => $allow_navigation, - straightLast => $allow_navigation, - 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, - drawZero => $y_draw_zero, - strokeColor => $self->get_color($axes->style('grid_color')), - strokeOpacity => $axes->style('grid_alpha') / 200, - insertTicks => 0, - ticksDistance => $axes->yaxis('tick_delta'), - majorHeight => $axes->yaxis('show_ticks') ? ($show_grid && $axes->yaxis('major') ? -1 : 10) : 0, - minorTicks => $axes->yaxis('minor'), - minorHeight => $axes->yaxis('show_ticks') ? ($show_grid && $axes->yaxis('major') ? -1 : 7) : 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')) . ')' - if $axes->yaxis('jsx_options'); - - $self->{JSend} = ''; - $self->{JS} = <<~ "END_JS"; - const board = JXG.JSXGraph.initBoard(id, $JSXOptions); - const descriptionSpan = document.createElement('span'); - descriptionSpan.id = `\${id}_description`; - descriptionSpan.classList.add('visually-hidden'); - descriptionSpan.textContent = '${\($axes->style('aria_description'))}'; - board.containerObj.after(descriptionSpan); - board.containerObj.setAttribute('aria-describedby', descriptionSpan.id); - board.suspendUpdate(); - 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; $self->{name} = $plots->get_image_name =~ s/-/_/gr; - $self->init_graph; - - # Plot Data - for my $data ($plots->data('function', 'dataset', 'circle', 'arc', 'multipath')) { + # Plot data, vector/slope fields, and points. Note that points + # are in a separate data call so that they are drawn last. + for my $data ($plots->data('function', 'dataset', 'circle', 'arc', 'multipath', 'vectorfield'), + $plots->data('point')) + { if ($data->name eq 'circle') { $self->add_circle($data); } elsif ($data->name eq 'arc') { $self->add_arc($data); } elsif ($data->name eq 'multipath') { $self->add_multipath($data); + } elsif ($data->name eq 'vectorfield') { + $self->add_vectorfield($data); } else { - $self->add_curve($data); + $self->add_curve($data) unless $data->name eq 'point'; $self->add_points($data); } } - # Vector/Slope Fields - for my $data ($plots->data('vectorfield')) { - my $f = $data->{function}; - my $xfunction = $data->function_string($f->{Fx}, 'js', $f->{xvar}, $f->{yvar}); - my $yfunction = $data->function_string($f->{Fy}, 'js', $f->{xvar}, $f->{yvar}); - - if ($xfunction ne '' && $yfunction ne '') { - my $options = $self->get_options( - $data, - scale => $data->style('scale') || 1, - ($data->style('slopefield') ? (arrowhead => { enabled => 0 }) : ()), - ); - $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} .= "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."; - } - } - # Stamps for my $stamp ($plots->data('stamp')) { my $mark = $stamp->style('symbol'); @@ -566,7 +586,7 @@ sub draw { my $y = $stamp->y(0); my $size = $stamp->style('radius') || 4; - $self->add_point($stamp, $x, $y, $size, $mark); + $self->add_point($stamp, $x, $y, $size, $stamp->style('width') || 2, $mark); } # Labels @@ -574,18 +594,27 @@ sub draw { my $str = $label->style('label'); my $x = $label->x(0); my $y = $label->y(0); - my $fontsize = $label->style('fontsize') || 'medium'; + my $fontsize = $label->style('fontsize') || 'normalsize'; 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 => $label->style('rotate') || 0, + fontSize => { + tiny => 8, + small => 10, + normalsize => 12, + medium => 12, # deprecated + large => 14, + Large => 16, + giant => 16, # deprecated + Large => 16, + huge => 20, + Huge => 23 + }->{$fontsize}, + $label->style('rotate') ? (rotate => $label->style('rotate')) : (), strokeColor => $self->get_color($label->style('color')), anchorX => $h_align eq 'center' ? 'middle' : $h_align, anchorY => $v_align, - cssStyle => 'padding: 3px;', + cssStyle => 'padding: 3px 5px;', useMathJax => 1, }); $textOptions = "JXG.merge($textOptions, " . Mojo::JSON::encode_json($label->style('jsx_options')) . ')' diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm index 660edba168..775a06365e 100644 --- a/lib/Plots/Plot.pm +++ b/lib/Plots/Plot.pm @@ -16,27 +16,24 @@ use Plots::Axes; use Plots::Data; use Plots::Tikz; use Plots::JSXGraph; -use Plots::GD; sub new { my ($class, %options) = @_; my $self = bless { - imageName => {}, - width => eval('$main::envir{onTheFlyImageSize}') || 350, - height => undef, - tex_size => 600, - axes => Plots::Axes->new, - colors => {}, - data => [], + imageName => {}, + width => eval('$main::envir{onTheFlyImageSize}') || 350, + height => undef, + tex_size => 600, + rounded_corners => 0, + axes => Plots::Axes->new, + colors => {}, + data => [], }, $class; # Besides for these core options, pass everything else to the Axes object. - for ('width', 'height', 'tex_size') { - if ($options{$_}) { - $self->{$_} = $options{$_}; - delete $options{$_}; - } + for ('width', 'height', 'tex_size', 'rounded_corners') { + $self->{$_} = delete $options{$_} if $options{$_}; } $self->axes->set(%options) if %options; @@ -48,13 +45,12 @@ sub new { sub pgCall { my ($call, @args) = @_; - WeBWorK::PG::Translator::PG_restricted_eval('\&' . $call)->(@args); - return; + return WeBWorK::PG::Translator::PG_restricted_eval('\&' . $call)->(@args); } sub add_js_file { - my ($self, $file) = @_; - pgCall('ADD_JS_FILE', $file); + my ($self, $file, $attributes) = @_; + pgCall('ADD_JS_FILE', $file, 0, $attributes); return; } @@ -164,6 +160,17 @@ sub image_type { my ($self, $type, $ext) = @_; return $self->{type} unless $type; + # Hardcopy uses the Tikz 'pdf' extension and PTX uses the Tikz 'tgz' extension. + if ($self->{pg}{displayMode} eq 'TeX') { + $self->{type} = 'Tikz'; + $self->{ext} = 'pdf'; + return; + } elsif ($self->{pg}{displayMode} eq 'PTX') { + $self->{type} = 'Tikz'; + $self->{ext} = 'tgz'; + return; + } + # Check type and extension are valid. The first element of @validExt is used as default. my @validExt; $type = lc($type); @@ -173,9 +180,6 @@ sub image_type { } elsif ($type eq 'tikz') { $self->{type} = 'Tikz'; @validExt = ('svg', 'png', 'pdf', 'gif', 'tgz'); - } elsif ($type eq 'gd') { - $self->{type} = 'GD'; - @validExt = ('png', 'gif'); } else { warn "Plots: Invalid image type $type."; return; @@ -191,14 +195,6 @@ sub image_type { $self->{ext} = $validExt[0]; } - # Hardcopy uses the Tikz 'pdf' extension and PTX uses the Tikz 'tgz' extension. - if ($self->{pg}{displayMode} eq 'TeX') { - $self->{type} = 'Tikz'; - $self->{ext} = 'pdf'; - } elsif ($self->{pg}{displayMode} eq 'PTX') { - $self->{type} = 'Tikz'; - $self->{ext} = 'tgz'; - } return; } @@ -215,7 +211,7 @@ sub tikz_code { # Add functions to the graph. sub _add_function { - my ($self, $Fx, $Fy, $var, $min, $max, @rest) = @_; + my ($self, $Fx, $Fy, $var, $min, $max, %rest) = @_; $var = 't' unless $var; $Fx = $var unless defined($Fx); @@ -229,9 +225,10 @@ sub _add_function { xmax => $max, color => 'default_color', width => 2, + mark_size => 2, dashed => 0, tikz_smooth => 1, - @rest + %rest ); $self->add_data($data); @@ -297,11 +294,8 @@ sub add_function { sub add_multipath { my ($self, $paths, $var, %options) = @_; my $data = Plots::Data->new(name => 'multipath'); - my $steps = 500; # Steps set high to help Tikz deal with boundaries of paths. - if ($options{steps}) { - $steps = $options{steps}; - delete $options{steps}; - } + my $steps = 100 * @$paths; # Steps set high to help Tikz deal with boundaries of paths. + $steps = delete $options{steps} if $options{steps}; $data->{context} = $self->context; $data->{paths} = [ map { { @@ -312,7 +306,7 @@ sub add_multipath { } } @$paths ]; $data->{function} = { var => $var, steps => $steps }; - $data->style(color => 'default_color', width => 2, %options); + $data->style(color => 'default_color', width => 2, mark_size => 2, %options); $self->add_data($data); return $data; @@ -329,8 +323,9 @@ sub _add_dataset { $data->add(@{ shift(@points) }); } $data->style( - color => 'default_color', - width => 2, + color => 'default_color', + width => 2, + mark_size => 2, @points ); @@ -351,9 +346,10 @@ sub _add_circle { my $data = Plots::Data->new(name => 'circle'); $data->add(@$point); $data->style( - radius => $radius, - color => 'default_color', - width => 2, + radius => $radius, + color => 'default_color', + width => 2, + mark_size => 2, @options ); @@ -374,8 +370,9 @@ sub _add_arc { my $data = Plots::Data->new(name => 'arc'); $data->add($point1, $point2, $point3); $data->style( - color => 'default_color', - width => 2, + color => 'default_color', + width => 2, + mark_size => 2, @options ); @@ -396,18 +393,19 @@ sub add_vectorfield { my $data = Plots::Data->new(name => 'vectorfield'); $data->set_function( $self->context, - Fx => '', - Fy => '', - xvar => 'x', - yvar => 'y', - xmin => -5, - xmax => 5, - ymin => -5, - ymax => 5, - xsteps => 15, - ysteps => 15, - width => 1, - color => 'default_color', + Fx => '', + Fy => '', + xvar => 'x', + yvar => 'y', + xmin => -5, + xmax => 5, + ymin => -5, + ymax => 5, + xsteps => 15, + ysteps => 15, + width => 1, + mark_size => 1, + color => 'default_color', @options ); @@ -417,15 +415,17 @@ sub add_vectorfield { sub _add_label { my ($self, $x, $y, @options) = @_; - my $data = Plots::Data->new(name => 'label'); + my $data = Plots::Data->new(name => 'label'); + my $label = @options % 2 ? shift @options : ''; $data->add($x, $y); $data->style( color => 'default_color', fontsize => 'medium', orientation => 'horizontal', + rotate => 0, h_align => 'center', v_align => 'middle', - label => '', + label => $label, @options ); @@ -438,22 +438,17 @@ sub add_label { return ref($labels[0]) eq 'ARRAY' ? [ map { $self->_add_label(@$_); } @labels ] : $self->_add_label(@labels); } -# Fill regions only work with GD and are ignored in TikZ images. -sub _add_fill_region { - my ($self, $x, $y, $color) = @_; - my $data = Plots::Data->new(name => 'fill_region'); - $data->add($x, $y); - $data->style(color => $color || 'default_color'); - $self->add_data($data); +sub _add_point { + my ($self, $x, $y, %options) = @_; + $options{marks} = delete $options{mark} if $options{mark} && !defined $options{marks}; + my $data = $self->_add_dataset([ $x, $y ], marks => 'circle', %options); + $data->{name} = 'point'; return $data; } -sub add_fill_region { - my ($self, @regions) = @_; - return - ref($regions[0]) eq 'ARRAY' - ? [ map { $self->_add_fill_region(@$_); } @regions ] - : $self->_add_fill_region(@regions); +sub add_point { + my ($self, @points) = @_; + return ref($points[0]) eq 'ARRAY' ? [ map { $self->_add_point(@$_); } @points ] : $self->_add_point(@points); } sub _add_stamp { @@ -462,7 +457,7 @@ sub _add_stamp { $data->add($x, $y); $data->style( color => 'default_color', - size => 4, + radius => 4, symbol => 'circle', @options ); @@ -485,8 +480,6 @@ sub draw { $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 08d18eacbf..02cedace32 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -15,19 +15,23 @@ use warnings; sub new { my ($class, $plots) = @_; my $image = LaTeXImage->new; - $image->environment([ 'tikzpicture', 'framed' ]); + $image->environment(['tikzpicture']); $image->svgMethod(eval('$main::envir{latexImageSVGMethod}') // 'dvisvgm'); $image->convertOptions(eval('$main::envir{latexImageConvertOptions}') // { input => {}, output => {} }); $image->ext($plots->ext); - $image->tikzLibraries('arrows.meta,plotmarks,backgrounds'); + $image->tikzLibraries('arrows.meta,plotmarks,calc'); $image->texPackages(['pgfplots']); - # 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. + # Set the pgfplots compatibility, add the pgfplots fillbetween library, define a save + # box that is used to wrap the axes in a nice rectangle frame with a white background, and redefine + # standard layers to include a background layer for the background. + # Note that "axis tick labels" is moved after "pre main" and "main" in the standard layer set. That is different + # than the pgfplots defaults, but is consistent with where JSXGraph places them, and is better than what pgplots + # does. Axis tick labels are textual elements that should be in front of the things that are drawn and together + # with the "axis descriptions". $image->addToPreamble( <<~ 'END_PREAMBLE'); \usepgfplotslibrary{fillbetween} - \tikzset{inner frame sep = 0pt, background rectangle/.style = { thick, draw = DarkBlue, fill = white }} + \newsavebox{\axesBox} \pgfplotsset{ compat = 1.18, layers/standard/.define layer set = { @@ -36,9 +40,9 @@ sub new { axis grid, axis ticks, axis lines, - axis tick labels, pre main, main, + axis tick labels, axis descriptions, axis foreground }{ @@ -68,7 +72,7 @@ sub new { } END_PREAMBLE - return bless { image => $image, plots => $plots, colors => {} }, $class; + return bless { image => $image, plots => $plots, colors => {}, names => { xaxis => 1 } }, $class; } sub plots { @@ -84,7 +88,9 @@ sub im { sub get_color { my ($self, $color) = @_; return '' if $self->{colors}{$color}; - my ($r, $g, $b) = @{ $self->plots->colors($color) }; + my $colorParts = $self->plots->colors($color); + return '' unless ref $colorParts eq 'ARRAY'; # Try to use the color by name if it wasn't defined. + my ($r, $g, $b) = @$colorParts; $self->{colors}{$color} = 1; return "\\definecolor{$color}{RGB}{$r,$g,$b}\n"; } @@ -94,183 +100,460 @@ sub get_mark { return { circle => '*', closed_circle => '*', - open_circle => 'o', + open_circle => '*, mark options={fill=white}', square => 'square*', - open_square => 'square', + open_square => 'square*, mark options={fill=white}', plus => '+', times => 'x', bar => '|', dash => '-', triangle => 'triangle*', - open_triangle => 'triangle', + open_triangle => 'triangle*, mark options={fill=white}', diamond => 'diamond*', - open_diamond => 'diamond', + open_diamond => 'diamond*, mark options={fill=white}', }->{$mark}; } -sub configure_axes { - my $self = shift; +# This is essentially copied from contextFraction.pl, and is exactly copied from parserGraphTool.pl. +# FIXME: Clearly there needs to be a single version of this somewhere that all three can use. +sub continuedFraction { + my ($x) = @_; + + my $step = $x; + my $n = int($step); + my ($h0, $h1, $k0, $k1) = (1, $n, 0, 1); + + while ($step != $n) { + $step = 1 / ($step - $n); + $n = int($step); + my ($newh, $newk) = ($n * $h1 + $h0, $n * $k1 + $k0); + last if $newk > 10**8; # Bail if the denominator is skyrocketing out of control. + ($h0, $h1, $k0, $k1) = ($h1, $newh, $k1, $newk); + } + + return ($h1, $k1); +} + +sub formatTickLabelText { + my ($self, $value, $axis) = @_; + my $tickFormat = $self->plots->axes->$axis('tick_label_format'); + if ($tickFormat eq 'fraction' || $tickFormat eq 'mixed') { + my ($num, $den) = continuedFraction(abs($value)); + if ($num && $den != 1 && !($num == 1 && $den == 1)) { + if ($tickFormat eq 'fraction' || $num < $den) { + $value = ($value < 0 ? '-' : '') . "\\frac{$num}{$den}"; + } else { + my $int = int($num / $den); + my $properNum = $num % $den; + $value = ($value < 0 ? '-' : '') . "$int\\frac{$properNum}{$den}"; + } + } + } elsif ($tickFormat eq 'scinot') { + my ($mantissa, $exponent) = split('e', sprintf('%e', $value)); + $value = + Plots::Plot::pgCall('Round', $mantissa, $self->plots->axes->$axis('tick_label_digits') // 2) + . "\\cdot 10^{$exponent}"; + } else { + $value = + sprintf('%f', Plots::Plot::pgCall('Round', $value, $self->plots->axes->$axis('tick_label_digits') // 2)); + if ($value =~ /\./) { + $value =~ s/0*$//; + $value =~ s/\.$//; + } + } + my $scaleSymbol = $self->plots->axes->$axis('tick_scale_symbol'); + return '\\(' + . ($value eq '0' ? '0' + : $scaleSymbol ? ($value eq '1' ? $scaleSymbol : $value eq '-1' ? "-$scaleSymbol" : "$value$scaleSymbol") + : $value) . '\\)'; +} + +sub generate_axes { + my ($self, $plotContents) = @_; my $plots = $self->plots; my $axes = $plots->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 $xvisible = $axes->xaxis('visible'); - my $yvisible = $axes->yaxis('visible'); - my $xmajor = $show_grid && $xvisible && $grid->{xmajor} && $axes->xaxis('show_ticks') ? 'true' : 'false'; - my $xminor_num = $grid->{xminor}; - my $xminor = $show_grid && $xvisible && $xmajor eq 'true' && $xminor_num > 0 ? 'true' : 'false'; - my $ymajor = $show_grid && $yvisible && $grid->{ymajor} && $axes->yaxis('show_ticks') ? 'true' : 'false'; - my $yminor_num = $grid->{yminor}; - my $yminor = $show_grid && $yvisible && $ymajor eq 'true' && $yminor_num > 0 ? 'true' : 'false'; - my $xticks = $axes->xaxis('show_ticks') ? "xtick distance=$grid->{xtick_delta}" : 'xtick=\empty'; - my $yticks = $axes->yaxis('show_ticks') ? "ytick distance=$grid->{ytick_delta}" : 'ytick=\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'); - 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 $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 ? ",\naxis x line shift=" . (-$axis_x_pos) : ''; - $axis_y_pos = $axis_y_pos ? ",\naxis y line shift=" . (-$axis_y_pos) : ''; - - unless ($xvisible) { - $xlabel = ''; - $hide_x_axis = "\nx axis line style={draw=none},\n" . "x tick style={draw=none},\n" . "xticklabel=\\empty,"; + my $show_grid = $axes->style('show_grid'); + my $xvisible = $axes->xaxis('visible'); + my $yvisible = $axes->yaxis('visible'); + my $xmajor = $show_grid && $grid->{xmajor} ? 'true' : 'false'; + my $xminor = $show_grid && $xmajor eq 'true' && $grid->{xminor_grids} && $grid->{xminor} > 0 ? 'true' : 'false'; + my $ymajor = $show_grid && $grid->{ymajor} ? 'true' : 'false'; + my $yminor = $show_grid && $ymajor eq 'true' && $grid->{yminor_grids} && $grid->{yminor} > 0 ? 'true' : 'false'; + my $grid_color = $axes->style('grid_color'); + my $grid_color_def = $self->get_color($grid_color); + my $grid_alpha = $axes->style('grid_alpha') / 100; + my $xaxis_location = $axes->xaxis('location'); + my $xaxis_pos = $xaxis_location eq 'middle' ? $axes->xaxis('position') : 0; + my $yaxis_location = $axes->yaxis('location'); + my $yaxis_pos = $yaxis_location eq 'center' ? $axes->yaxis('position') : 0; + my $axis_on_top = $axes->style('axis_on_top') ? "axis on top,\n" : ''; + my $negativeArrow = $axes->style('axes_arrows_both') ? 'Latex[{round,scale=1.6}]' : ''; + my $tikz_options = $axes->style('tikz_options') // ''; + + my $xlabel = $xvisible ? $axes->xaxis('label') : ''; + my $xaxis_style = + $xvisible + ? ",\nx axis line style={$negativeArrow-Latex[{round,scale=1.6}]}" + : ",\nx axis line style={draw=none},\nextra y ticks={0}"; + my $xtick_style = + $xvisible && $axes->xaxis('show_ticks') ? ",\nx tick style={line width=0.6pt}" : ",\nx tick style={draw=none}"; + + my $ylabel = $yvisible ? $axes->yaxis('label') : ''; + my $yaxis_style = + $yvisible + ? ",\ny axis line style={$negativeArrow-Latex[{round,scale=1.6}]}" + : ",\ny axis line style={draw=none},\nextra x ticks={0}"; + my $ytick_style = + $yvisible && $axes->yaxis('show_ticks') ? ",\ny tick style={line width=0.6pt}" : ",\ny tick style={draw=none}"; + + my $x_tick_distance = $axes->xaxis('tick_distance'); + my $x_tick_scale = $axes->xaxis('tick_scale') || 1; + + my @xticks = + grep { $_ > $xmin && $_ < $xmax } + map { -$_ * $x_tick_distance * $x_tick_scale } + reverse(1 .. -$xmin / ($x_tick_distance * $x_tick_scale)); + push(@xticks, 0) if $xmin < 0 && $xmax > 0; + push(@xticks, + grep { $_ > $xmin && $_ < $xmax } + map { $_ * $x_tick_distance * $x_tick_scale } (1 .. $xmax / ($x_tick_distance * $x_tick_scale))); + + my $xtick_labels = + $xvisible + && $axes->xaxis('show_ticks') + && $axes->xaxis('tick_labels') + ? (",\nxticklabel shift=9pt,\nxticklabel style={anchor=center},\nxticklabels={" + . join(',', map { $self->formatTickLabelText($_ / $x_tick_scale, 'xaxis') } @xticks) . '}') + : ",\nxticklabel=\\empty"; + + my @xminor_ticks; + if ($grid->{xminor} > 0) { + my @majorTicks = @xticks; + unshift(@majorTicks, ($majorTicks[0] // $xmin) - $x_tick_distance * $x_tick_scale); + push(@majorTicks, ($majorTicks[-1] // $xmax) + $x_tick_distance * $x_tick_scale); + my $x_minor_delta = $x_tick_distance * $x_tick_scale / ($grid->{xminor} + 1); + for my $tickIndex (0 .. $#majorTicks - 1) { + push(@xminor_ticks, + grep { $_ > $xmin && $_ < $xmax } + map { $majorTicks[$tickIndex] + $_ * $x_minor_delta } 1 .. $grid->{xminor}); + } } - unless ($yvisible) { - $ylabel = ''; - $hide_y_axis = "\ny axis line style={draw=none},\n" . "y tick style={draw=none},\n" . "yticklabel=\\empty,"; + + my $y_tick_distance = $axes->yaxis('tick_distance'); + my $y_tick_scale = $axes->yaxis('tick_scale') || 1; + + my @yticks = + grep { $_ > $ymin && $_ < $ymax } + map { -$_ * $y_tick_distance * $y_tick_scale } + reverse(1 .. -$ymin / ($y_tick_distance * $y_tick_scale)); + push(@yticks, 0) if $ymin < 0 && $ymax > 0; + push(@yticks, + grep { $_ > $ymin && $_ < $ymax } + map { $_ * $y_tick_distance * $y_tick_scale } (1 .. $ymax / ($y_tick_distance * $y_tick_scale))); + + my $ytick_labels = + $yvisible + && $axes->yaxis('show_ticks') + && $axes->yaxis('tick_labels') + ? (",\nyticklabel shift=-3pt,\nyticklabels={" + . join(',', map { $self->formatTickLabelText($_ / $y_tick_scale, 'yaxis') } @yticks) . '}') + : ",\nyticklabel=\\empty"; + + my @yminor_ticks; + if ($grid->{yminor} > 0) { + my @majorTicks = @yticks; + unshift(@majorTicks, ($majorTicks[0] // $ymin) - $y_tick_distance * $y_tick_scale); + push(@majorTicks, ($majorTicks[-1] // $ymax) + $y_tick_distance * $y_tick_scale); + my $y_minor_delta = $y_tick_distance * $y_tick_scale / ($grid->{yminor} + 1); + for my $tickIndex (0 .. $#majorTicks - 1) { + push(@yminor_ticks, + grep { $_ > $ymin && $_ < $ymax } + map { $majorTicks[$tickIndex] + $_ * $y_minor_delta } 1 .. $grid->{yminor}); + } } + + my $xaxis_plot = ($ymin <= 0 && $ymax >= 0) ? "\\path[name path=xaxis] ($xmin, 0) -- ($xmax, 0);" : ''; + $xaxis_pos = $xaxis_pos ? ",\naxis x line shift=" . (($ymin > 0 ? $ymin : $ymax < 0 ? $ymax : 0) - $xaxis_pos) : ''; + $yaxis_pos = $yaxis_pos ? ",\naxis y line shift=" . (($xmin > 0 ? $xmin : $xmax < 0 ? $xmax : 0) - $yaxis_pos) : ''; + + my $roundedCorners = $plots->{rounded_corners} ? 'rounded corners = 10pt' : ''; + my $left = + $yvisible && ($yaxis_location eq 'left' || $yaxis_location eq 'box' || $xmin == $axes->yaxis('position')) + ? 'outer west' + : 'west'; + my $right = $yvisible && ($yaxis_location eq 'right' || $xmax == $axes->yaxis('position')) ? 'outer east' : 'east'; + my $lower = + $xvisible && ($xaxis_location eq 'bottom' || $xaxis_location eq 'box' || $ymin == $axes->xaxis('position')) + ? 'outer south' + : 'south'; + my $upper = $xvisible && ($xaxis_location eq 'top' || $ymax == $axes->xaxis('position')) ? 'outer north' : 'north'; + + # The savebox only actually saves the main layer. All other layers are actually drawn when the savebox is saved. + # So clipping of anything drawn on any other layer has to be done when things are drawn on the other layers. The + # axisclippath is used for this. The main layer is clipped at the end when the savebox is used. my $tikzCode = <<~ "END_TIKZ"; - \\begin{axis} - [ - trig format plots=rad, - view={0}{90}, - scale only axis, - height=$axes_height, - width=$axes_width, - ${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}, - $xticks,$xtick_labels - $yticks,$ytick_labels - xmajorgrids=$xmajor, - xminorgrids=$xminor, - minor x tick num=$xminor_num, - ymajorgrids=$ymajor, - yminorgrids=$yminor, - minor y tick num=$yminor_num, - grid style={$grid_color!$grid_alpha}, - xmin=$xmin, - xmax=$xmax, - ymin=$ymin, - ymax=$ymax,$hide_x_axis$hide_y_axis - ] - $grid_color2$xaxis_plot + \\pgfplotsset{set layers=${\($axes->style('axis_on_top') ? 'axis on top' : 'standard')}}% + $grid_color_def + \\savebox{\\axesBox}{ + \\Large + \\begin{axis} + [ + trig format plots=rad, + scale only axis, + height=$axes_height, + width=$axes_width, + ${axis_on_top}axis x line=$xaxis_location$xaxis_pos$xaxis_style, + axis y line=$yaxis_location$yaxis_pos$yaxis_style, + xlabel={$xlabel}, + ylabel={$ylabel}, + xtick={${\(join(',', @xticks))}}$xtick_style$xtick_labels, + minor xtick={${\(join(',', @xminor_ticks))}}, + ytick={${\(join(',', @yticks))}}$ytick_style$ytick_labels, + minor ytick={${\(join(',', @yminor_ticks))}}, + xtick scale label code/.code={}, + ytick scale label code/.code={}, + major tick length=0.3cm, + minor tick length=0.2cm, + xmajorgrids=$xmajor, + xminorgrids=$xminor, + ymajorgrids=$ymajor, + yminorgrids=$yminor, + grid style={$grid_color, opacity=$grid_alpha}, + xmin=$xmin, + xmax=$xmax, + ymin=$ymin, + ymax=$ymax,$tikz_options + ] + $xaxis_plot + \\newcommand{\\axisclippath}{(current axis.south west) [${\( + $roundedCorners && ($lower !~ /^outer/ || $right !~ /^outer/) ? $roundedCorners : 'sharp corners' + )}] -- (current axis.south east) [${\( + $roundedCorners && ($upper !~ /^outer/ || $right !~ /^outer/) ? $roundedCorners : 'sharp corners' + )}] -- (current axis.north east) [${\( + $roundedCorners && ($upper !~ /^outer/ || $left !~ /^outer/) ? $roundedCorners : 'sharp corners' + )}] -- (current axis.north west) [${\( + $roundedCorners && ($lower !~ /^outer/ || $left !~ /^outer/) ? $roundedCorners : 'sharp corners' + )}] -- cycle} + END_TIKZ + + $tikzCode .= $plotContents; + $tikzCode .= $plots->{extra_tikz_code} if $plots->{extra_tikz_code}; + + $tikzCode .= <<~ "END_TIKZ"; + \\end{axis} + } + \\pgfresetboundingbox + \\begin{pgfonlayer}{background} + \\filldraw[draw = DarkBlue, fill = white, $roundedCorners, line width = 0.5pt] + (\$(current axis.$left |- current axis.$lower)-(0.25pt,0.25pt)\$) + rectangle + (\$(current axis.$right |- current axis.$upper)+(0.25pt,0.25pt)\$); + \\end{pgfonlayer} + \\begin{scope} + \\clip[$roundedCorners] + (\$(current axis.$left |- current axis.$lower)-(0.25pt,0.25pt)\$) + rectangle + (\$(current axis.$right |- current axis.$upper)+(0.25pt,0.25pt)\$); + \\usebox{\\axesBox} + \\end{scope} + \\begin{pgfonlayer}{axis foreground} + \\draw[draw = DarkBlue, $roundedCorners, line width = 0.5pt, use as bounding box] + (\$(current axis.$left |- current axis.$lower)-(0.25pt,0.25pt)\$) + rectangle + (\$(current axis.$right |- current axis.$upper)+(0.25pt,0.25pt)\$); + \\end{pgfonlayer} END_TIKZ chop($tikzCode); - return $tikzCode =~ s/^\t//gr; + return $tikzCode; } -sub get_plot_opts { +sub get_options { my ($self, $data) = @_; - my $color = $data->style('color') || 'default_color'; - my $width = $data->style('width'); - 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') || $data->style('color') || 'default_color'; - my $fill_opacity = $data->style('fill_opacity') || 0.5; - 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' : '') . ']}'; - } elsif ($start eq 'arrow') { - my $arrow_width = $data->style('arrow_size') || 10; - my $arrow_length = int(1.5 * $arrow_width); - $start = "{Stealth[length=${arrow_length}pt,width=${arrow_width}pt]}"; - } else { - $start = ''; + + my $fill = $data->style('fill') || 'none'; + my $drawLayer = $data->style('layer'); + my $fillLayer = $data->style('fill_layer') || $drawLayer; + my $marks = $self->get_mark($data->style('marks')); + + my $drawFillSeparate = + $fill eq 'self' + && ($data->style('linestyle') ne 'none' || $marks) + && defined $fillLayer + && (!defined $drawLayer || $drawLayer ne $fillLayer); + + my (@drawOptions, @fillOptions); + + if ($data->style('linestyle') ne 'none' || $marks) { + my $linestyle = { + none => 'draw=none', + solid => 'solid', + dashed => 'dash={on 11pt off 8pt phase 6pt}', + short_dashes => 'dash pattern={on 6pt off 3pt}', + long_dashes => 'dash={on 20pt off 15pt phase 10pt}', + dotted => 'dotted', + long_medium_dashes => 'dash={on 20pt off 7pt on 11pt off 7pt phase 10pt}', + }->{ ($data->style('linestyle') || 'solid') =~ s/ /_/gr } + || 'solid'; + push(@drawOptions, $linestyle); + + my $width = $data->style('width'); + push(@drawOptions, "line width=${width}pt", "color=" . ($data->style('color') || 'default_color')); + + if ($linestyle ne 'draw=none') { + my $start = $data->style('start_mark') || ''; + if ($start =~ /circle/) { + $start = + '{Circle[sep=-1.196825pt -1.595769' . ($start eq 'open_circle' ? ', open,fill=white' : '') . ']}'; + } elsif ($start eq 'arrow') { + my $arrow_width = $width * ($data->style('arrow_size') || 8); + $start = "{Stealth[length=${arrow_width}pt 1,width'=0pt 1,inset'=0pt 0.5]}"; + } else { + $start = ''; + } + + my $end = $data->style('end_mark') || ''; + if ($end =~ /circle/) { + $end = '{Circle[sep=-1.196825pt -1.595769' . ($end eq 'open_circle' ? ', open,fill=white' : '') . ']}'; + } elsif ($end eq 'arrow') { + my $arrow_width = $width * ($data->style('arrow_size') || 8); + $end = "{Stealth[length=${arrow_width}pt 1,width'=0pt 1,inset'=0pt 0.5]}"; + } else { + $end = ''; + } + + push(@drawOptions, "$start-$end") if $start || $end; + } + + if ($marks) { + push(@drawOptions, "mark=$marks"); + + my $mark_size = $data->style('mark_size') || 0; + if ($mark_size) { + $mark_size = $mark_size + $width / 2 if $marks =~ /^[*+]/; + $mark_size = $mark_size + $width if $marks eq 'x'; + push(@drawOptions, "mark size=${mark_size}pt"); + } + } + + push(@drawOptions, 'smooth') if $data->style('tikz_smooth'); } - if ($end =~ /circle/) { - $end = '{Circle[sep=-1.196825pt -1.595769' . ($end eq 'open_circle' ? ', open' : '') . ']}'; - } elsif ($end eq 'arrow') { - my $arrow_width = $data->style('arrow_size') || 10; - my $arrow_length = int(1.5 * $arrow_width); - $end = "{Stealth[length=${arrow_length}pt,width=${arrow_width}pt]}"; - } else { - $end = ''; + + my $tikz_options = $data->style('tikz_options'); + + if ($drawFillSeparate) { + my $fill_color = $data->style('fill_color') || $data->style('color') || 'default_color'; + my $fill_opacity = $data->style('fill_opacity') || 0.5; + push(@fillOptions, 'draw=none', "fill=$fill_color", "fill opacity=$fill_opacity"); + push(@fillOptions, 'smooth') if $data->style('tikz_smooth'); + push(@fillOptions, $tikz_options) if $tikz_options; + } elsif ($fill eq 'self') { + if (!@drawOptions) { + push(@drawOptions, 'draw=none'); + $drawLayer = $fillLayer if defined $fillLayer; + } + my $fill_color = $data->style('fill_color') || $data->style('color') || 'default_color'; + my $fill_opacity = $data->style('fill_opacity') || 0.5; + push(@drawOptions, "fill=$fill_color", "fill opacity=$fill_opacity"); } - my $end_markers = ($start || $end) ? ", $start-$end" : ''; - $marks = $self->get_mark($marks); - $marks = $marks ? $mark_size ? ", mark=$marks, mark size=${mark_size}pt" : ", mark=$marks" : ''; - - $linestyle =~ s/ /_/g; - $linestyle = { - none => ', only marks', - solid => ', solid', - dashed => ', dash={on 11pt off 8pt phase 6pt}', - short_dashes => ', dash pattern={on 6pt off 3pt}', - long_dashes => ', dash={on 20pt off 15pt phase 10pt}', - dotted => ', dotted', - long_medium_dashes => ', dash={on 20pt off 7pt on 11pt off 7pt phase 10pt}', - }->{$linestyle} - || ', solid'; - - if ($fill eq 'self') { - $fill = ", fill=$fill_color, fill opacity=$fill_opacity"; - } else { - $fill = ''; + + my $name = $data->style('name'); + if ($name) { + warn 'Duplicate plot name detected. This will most likely cause issues. ' + . 'Make sure that all names used are unique.' + if $self->{names}{$name}; + $self->{names}{$name} = 1; + # This forces the curve to be inserted invisibly if it has been named, + # but the curve would otherwise not be drawn. + push(@drawOptions, 'draw=none') if !@drawOptions; + push(@drawOptions, "name path=$name"); } - $name = ", name path=$name" if $name; - return "${smooth}color=$color, line width=${width}pt$marks$linestyle$end_markers$fill$name$tikz_options"; + push(@drawOptions, $tikz_options) if $tikz_options; + + return ([ join(', ', @drawOptions), $drawLayer ], @fillOptions ? [ join(', ', @fillOptions), $fillLayer ] : undef); +} + +sub draw_on_layer { + my ($self, $plot, $layer) = @_; + my $tikzCode; + $tikzCode .= "\\begin{scope}[on layer=$layer]\\begin{pgfonlayer}{$layer}\\clip\\axisclippath;\n" if $layer; + $tikzCode .= $plot; + $tikzCode .= "\\end{pgfonlayer}\\end{scope}\n" if $layer; + return $tikzCode; } sub draw { - my $self = shift; - my $plots = $self->plots; - my $tikzFill = ''; + my $self = shift; + my $plots = $self->plots; # Reset colors just in case. $self->{colors} = {}; - # Add Axes - my $tikzCode = $self->configure_axes; + my $tikzCode = ''; + + # Plot data, vector/slope fields, and points. Note that points + # are in a separate data call so that they are drawn last. + for my $data ($plots->data('function', 'dataset', 'circle', 'arc', 'multipath', 'vectorfield'), + $plots->data('point')) + { + my $color = $data->style('color') || 'default_color'; + my $layer = $data->style('layer'); - # Plot Data - for my $data ($plots->data('function', 'dataset', 'circle', 'arc', 'multipath')) { - my $n = $data->size; - my $color = $data->style('color') || 'default_color'; - my $fill = $data->style('fill') || 'none'; - my $fill_color = $data->style('fill_color') || $data->style('color') || 'default_color'; - my $tikz_options = $self->get_plot_opts($data); $tikzCode .= $self->get_color($color); + + if ($data->name eq 'vectorfield') { + my $f = $data->{function}; + my $xfunction = $data->function_string($f->{Fx}, 'PGF', $f->{xvar}, $f->{yvar}); + my $yfunction = $data->function_string($f->{Fy}, 'PGF', $f->{xvar}, $f->{yvar}); + if ($xfunction ne '' && $yfunction ne '') { + my $width = $data->style('width'); + my $scale = $data->style('scale'); + my $arrows = $data->style('slopefield') ? '' : ', -stealth'; + my $tikz_options = $data->style('tikz_options') ? ', ' . $data->style('tikz_options') : ''; + $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; + } + + my $yDelta = ($f->{ymax} - $f->{ymin}) / $f->{ysteps}; + my $next = $f->{ymin} + $yDelta; + my $last = $f->{ymax} + $yDelta / 2; # Adjust upward incase of rounding error in the foreach. + my $xSamples = $f->{xsteps} + 1; + $tikzCode .= $self->draw_on_layer( + "\\foreach \\i in {$f->{ymin}, $next, ..., $last}\n" + . "\\addplot[color=$color, line width=${width}pt$arrows, " + . "quiver={u=$xfunction, v=$yfunction, scale arrows=$scale}, samples=$xSamples, " + . "domain=$f->{xmin}:$f->{xmax}$tikz_options] {\\i};\n", + $layer + ); + } else { + warn "Vector field not created due to missing PGF functions."; + } + next; + } + + my $fill = $data->style('fill') || 'none'; + my $fill_color = $data->style('fill_color') || $data->style('color') || 'default_color'; $tikzCode .= $self->get_color($fill_color) unless $fill eq 'none'; + my ($draw_options, $fill_options) = $self->get_options($data); + if ($data->name eq 'circle') { my $x = $data->x(0); my $y = $data->y(0); my $r = $data->style('radius'); - $tikzCode .= "\\draw[$tikz_options] (axis cs:$x,$y) circle [radius=$r];\n"; + $tikzCode .= $self->draw_on_layer("\\fill[$fill_options->[0]] (axis cs:$x,$y) circle[radius=$r];\n", + $fill_options->[1]) + if $fill_options; + $tikzCode .= $self->draw_on_layer("\\draw[$draw_options->[0]] (axis cs:$x,$y) circle[radius=$r];\n", + $draw_options->[1]); next; } if ($data->name eq 'arc') { @@ -282,19 +565,29 @@ sub draw { my $theta2 = 180 * atan2($y3 - $y1, $x3 - $x1) / 3.14159265358979; $theta1 += 360 if $theta1 < 0; $theta2 += 360 if $theta2 < 0; - $tikzCode .= "\\draw[$tikz_options] (axis cs:$x2,$y2) arc ($theta1:$theta2:$r);\n"; + $tikzCode .= $self->draw_on_layer( + "\\fill[$fill_options->[0]] (axis cs:$x2,$y2) " + . "arc[start angle=$theta1, end angle=$theta2, radius = $r];\n", + $fill_options->[1] + ) if $fill_options; + $tikzCode .= $self->draw_on_layer( + "\\draw[$draw_options->[0]] (axis cs:$x2,$y2) " + . "arc[start angle=$theta1, end angle=$theta2, radius = $r];\n", + $draw_options->[1] + ); next; } my $plot; + my $plot_options = ''; 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($f->{Fy}, 'PGF', $f->{xvar}); if ($function ne '') { $data->update_min_max; - $tikz_options .= ", data cs=polar" if $data->style('polar'); - $tikz_options .= ", domain=$f->{xmin}:$f->{xmax}, samples=$f->{xsteps}"; + $plot_options .= ", data cs=polar" if $data->style('polar'); + $plot_options .= ", domain=$f->{xmin}:$f->{xmax}, samples=$f->{xsteps}"; $plot = "{$function}"; } } else { @@ -302,7 +595,7 @@ sub draw { my $yfunction = $data->function_string($f->{Fy}, 'PGF', $f->{xvar}); if ($xfunction ne '' && $yfunction ne '') { $data->update_min_max; - $tikz_options .= ", domain=$f->{xmin}:$f->{xmax}, samples=$f->{xsteps}"; + $plot_options .= ", domain=$f->{xmin}:$f->{xmax}, samples=$f->{xsteps}"; $plot = "({$xfunction}, {$yfunction})"; } } @@ -329,73 +622,58 @@ sub draw { push(@tikzFunctionx, "(x>=$a)*(x<$last$b)*($xfunction)"); push(@tikzFunctiony, "(x>=$a)*(x<$last$b)*($yfunction)"); } - $tikz_options .= ", domain=0:1, samples=$data->{function}{steps}"; + $plot_options .= ", mark=none, domain=0:1, samples=$data->{function}{steps}"; $plot = "\n({" . join("\n+", @tikzFunctionx) . "},\n{" . join("\n+", @tikzFunctiony) . '})'; } unless ($plot) { $data->gen_data; - my $tikzData = join(' ', map { '(' . $data->x($_) . ',' . $data->y($_) . ')'; } (0 .. $n - 1)); - $plot = "coordinates {$tikzData}"; + $plot = 'coordinates {' + . join(' ', map { '(' . $data->x($_) . ',' . $data->y($_) . ')'; } (0 .. $data->size - 1)) . '}'; } - $tikzCode .= "\\addplot[$tikz_options] $plot;\n"; + $tikzCode .= $self->draw_on_layer("\\addplot[$fill_options->[0]$plot_options] $plot;\n", $fill_options->[1]) + if $fill_options; + $tikzCode .= $self->draw_on_layer("\\addplot[$draw_options->[0]$plot_options] $plot;\n", $draw_options->[1]); unless ($fill eq 'none' || $fill eq 'self') { - my $name = $data->style('name'); - if ($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 = $fill_min ne '' && $fill_max ne '' ? ", soft clip={domain=$fill_min:$fill_max}" : ''; - $opacity *= 100; - $tikzFill .= "\\addplot[$fill_color!$opacity] fill between[of=$name and $fill$fill_range];\n"; + if ($self->{names}{$fill}) { + my $name = $data->style('name'); + if ($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 = + $fill_min ne '' && $fill_max ne '' ? ", soft clip={domain=$fill_min:$fill_max}" : ''; + my $fill_layer = $data->style('fill_layer') || $layer; + $tikzCode .= + "\\begin{scope}[/tikz/fill between/on layer=$fill_layer]\\begin{pgfonlayer}{$fill_layer}" + . "\\clip\\axisclippath;\n" + if $fill_layer; + $tikzCode .= + "\\addplot[$fill_color, fill opacity=$opacity] fill between[of=$name and $fill$fill_range];\n"; + $tikzCode .= "\\end{pgfonlayer}\\end{scope}\n" if $fill_layer; + } else { + warn q{Unable to create fill. Missing 'name' attribute.}; + } } else { - warn "Unable to create fill. Missing 'name' attribute."; + warn q{Unable to fill between curves. Other graph has not yet been drawn.}; } } } - # Add fills last to ensure all named graphs have been plotted first. - $tikzCode .= $tikzFill; - - # Vector/Slope Fields - for my $data ($plots->data('vectorfield')) { - my $f = $data->{function}; - my $xfunction = $data->function_string($f->{Fx}, 'PGF', $f->{xvar}, $f->{yvar}); - my $yfunction = $data->function_string($f->{Fy}, 'PGF', $f->{xvar}, $f->{yvar}); - my $arrows = $data->style('slopefield') ? '' : ', -stealth'; - if ($xfunction ne '' && $yfunction ne '') { - 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')) { - 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}$tikz_options] {1};\n"; - } else { - warn "Vector field not created due to missing PGF functions."; - } - } # Stamps for my $stamp ($plots->data('stamp')) { - my $mark = $self->get_mark($stamp->style('symbol')); + 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); - 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"; + my $color = $stamp->style('color') || 'default_color'; + my $x = $stamp->x(0); + my $y = $stamp->y(0); + my $lineWidth = $stamp->style('width') || 2; + my $r = ($stamp->style('radius') || 4) + ($mark =~ /^[*+]/ ? $lineWidth / 2 : $mark eq 'x' ? $lineWidth : 0); + $tikzCode .= + $self->get_color($color) + . "\\addplot[$color, mark=$mark, mark size=${r}pt, line width=${lineWidth}pt, only marks] " + . "coordinates {($x,$y)};\n"; } # Labels @@ -404,30 +682,34 @@ sub draw { my $x = $label->x(0); my $y = $label->y(0); my $color = $label->style('color') || 'default_color'; - my $fontsize = $label->style('fontsize') || 'medium'; + my $fontsize = $label->style('fontsize') || 'normalsize'; my $rotate = $label->style('rotate'); 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' : ''; + my $anchor = join(' ', + $v_align eq 'top' ? 'north' : $v_align eq 'bottom' ? 'south' : (), + $h_align eq 'left' ? 'west' : $h_align eq 'right' ? 'east' : ()); $str = { - tiny => '\tiny ', - small => '\small ', - medium => '', - large => '\large ', - giant => '\Large ', + tiny => '\tiny ', + small => '\small ', + normalsize => '', + medium => '', # deprecated + large => '\large ', + Large => '\Large ', + giant => '\Large ', # deprecated + huge => '\huge ', + Huge => '\Huge ' }->{$fontsize} . $str; - $anchor .= $h_align eq 'left' ? ' west' : $h_align eq 'right' ? ' east' : ''; $tikz_options = $tikz_options ? "$color, $tikz_options" : $color; $tikz_options = "anchor=$anchor, $tikz_options" if $anchor; $tikz_options = "rotate=$rotate, $tikz_options" if $rotate; $tikzCode .= $self->get_color($color) . "\\node[$tikz_options] at (axis cs: $x,$y) {$str};\n"; } - $tikzCode .= '\end{axis}'; - $plots->{tikzCode} = $tikzCode; - $self->im->tex($tikzCode); + $plots->{tikzCode} = $self->generate_axes($tikzCode); + $self->im->tex($plots->{tikzCode}); return $plots->{tikzDebug} ? '' : $self->im->draw; } diff --git a/macros/graph/plots.pl b/macros/graph/plots.pl index 64ad60e51a..a41f2e6c52 100644 --- a/macros/graph/plots.pl +++ b/macros/graph/plots.pl @@ -7,9 +7,9 @@ =head1 DESCRIPTION This macro creates a Plots object that is used to add data of different elements of a 2D plot, then draw the plot. The plots can be drawn using different -formats. Currently C (using PGFplots), C, and the legacy C -graphics format are available. Default is to use C for HTML output and -C for hardcopy. +formats. Currently C (using PGFplots) and C graphics format are +available. The 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. @@ -36,6 +36,41 @@ =head1 USAGE Options that start with C configure the xaxis, options that start with C configure the yaxis, and all other options are Axes styles. +In addition to the options for configuring the L, the +following options may be passed. + +=over + +=item width + +The width of the image. The default value of this option is the +C value in the environment, or C<350> if that is not set or +is C<0>. + +=item height + +The height of the image. The default value of this option is the C. If +this is explicitly set to a positive integer value, then that height will be +used. If this is C, then the height of the image will be automatically +determined. If the C style setting for the C object +is C<1>, then the height will be computed to maintain the aspect ratio of the +image. Otherwise it will be set to value of the C option. + +=item tex_size + +The size of the image in hardcopy. See L +for more details on this setting. + +=item rounded_corners + +Determines if the image will be displayed in a rectangle with rounded corners +or sharp corners. The default value for this option is C<0> which means that +sharp corners will be used. If this is set to C<1>, then rounded corners will +be used. Note that this may not work well for images that have elements of the +plot near or in the corners. + +=back + Add a function and other objects to the plot. $plot->add_function('-16t^2 + 80t + 384', 't', 0, 8, color => 'blue', width => 3); @@ -59,11 +94,11 @@ =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], - [[$x21, $y21], [$x22, $y22], ..., [$x2m, $y2m], @options2], + [[$x11, $y11], [$x12, $y12], ..., [$x1n, $y1n], %options1], + [[$x21, $y21], [$x22, $y22], ..., [$x2m, $y2m], %options2], ... ); @@ -90,11 +125,11 @@ =head2 PLOT FUNCTIONS functions can be added individually or multiple at once: # Add a single function - $plot->add_function($function, $variable, $min, $max, @options) + $plot->add_function($function, $variable, $min, $max, %options) # Add multiple functions $plot->add_function( - [$function1, $variable1, $min1, $max1, @options1], - [$function2, $variable2, $min2, $max2, @options2], + [$function1, $variable1, $min1, $max1, %options1], + [$function2, $variable2, $min2, $max2, %options2], ... ); @@ -196,7 +231,7 @@ =head2 PLOT MULTIPATH FUNCTIONS =head2 PLOT CIRCLES -Circles can be added to the plot by specifing its center and radius using the +Circles can be added to the plot by specifying its center and radius using the C<< $plot->add_circle >> method. This can either be done either one at a time or multiple at once. @@ -209,15 +244,17 @@ =head2 PLOT CIRCLES =head2 PLOT ARCS -Arcs (or a portion of a circle) can be plotted using the C<< $plot->add_arc >> method. -This method takes three points. The first point is where the arc starts, the second point -is the center of the circle, and the third point specifies the ray from the center of -the circle the arc ends. Arcs always go in the counter clockwise direction. +Arcs (or a portion of a circle) can be plotted using the C<< $plot->add_arc >> +method. This method takes three points. The first point is the center of the +circle, the second point is where the arc starts, and the arc ends at the point +on the circle that intersects the ray from the center of the circle pointing in +the direction of the third point. Arcs always go in the counter clockwise +direction. $plot->add_arc([$start_x, $start_y], [$center_x, $center_y], [$end_x, $end_y], %options); $plot->add_arc( - [[$start_x1, $start_y1], [$center_x1, $center_y1], [$end_x1, $end_y1], %options1], - [[$start_x2, $start_y2], [$center_x2, $center_y2], [$end_x2, $end_y2], %options2], + [[$center_x1, $center_y1], [$start_x1, $start_y1], [$end_x1, $end_y1], %options1], + [[$center_x2, $center_y2], [$start_x2, $start_y2], [$end_x2, $end_y2], %options2], ... ); @@ -262,8 +299,9 @@ =head2 PLOT VECTOR FIELDS =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 +The number of steps from the domain minimum to the domain maximum at which to +draw arrows. The number of arrows drawn will be one more than the number of +steps. Default: 15 =item scale @@ -303,14 +341,17 @@ =head2 DATASET OPTIONS =item width -The line width of the plot. Default: 1 +The line width of the plot. Default: 2 =item linestyle -Linestyle can be one of 'solid', 'dashed', 'dotted', 'short dashes', 'long dashes', -'long medium dashes' (alternates between long and medium dashes), 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 'long_dashes'. Default: 'solid' +Linestyle can be one of 'solid', 'dashed', 'dotted', 'short dashes', 'long +dashes', 'long medium dashes' (alternates between long and medium dashes), or +'none'. If set to 'none', then the curve will not be drawn. This can be used to +show only points by setting the C option (see C for point +options), or to only show a fill region by setting the C option. For +convenience underscores can also be used, such as 'long_dashes'. +Default: 'solid' =item marks @@ -322,8 +363,7 @@ =head2 DATASET OPTIONS =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 +and represents the point (pt) size of the mark. Default: 2 =item start_mark @@ -338,7 +378,7 @@ =head2 DATASET OPTIONS =item arrow_size Sets the arrow head size for C or C arrows. -Default: 10 +Default: 8 =item name @@ -352,8 +392,9 @@ =head2 DATASET OPTIONS 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 C attribute must be set to -fill between the 'xaxis' or another curve. +the other dataset to fill against. Note that the other dataset must be +created first before attempting to fill against it. The C attribute +must be set to fill between the 'xaxis' or another curve. The following creates a filled rectangle: @@ -406,6 +447,25 @@ =head2 DATASET OPTIONS not defined, then the fill will use the full domain of the function. Default: undefined +=item layer + +The layer to draw on. Available layers are "axis background", "axis grid", +"axis ticks", "axis lines", "axis tick labels", "pre main", "main", +"axis descriptions", and "axis foreground". Note that the default order is the +order just given (from back to front). However, if C is true for +the axes, then "pre main" and "main" are after "axis background" and before +"axis grid". If this is undefined, then the default drawing layer will be used. +Default: undefined + +=item fill_layer + +The layer to place the fill region on. The curves will be drawn on the default +layer (or the layer specified by the C option) and the fill region will +be drawn on the layer specified by this option. Note that if this option is not +specified and the C option, then the curve and the fill region will both +be drawn on the specified C. See the C option above regarding +available layers to choose from. Default: undefined + =item steps This defines the number of points to generate for a dataset from a function. @@ -448,13 +508,16 @@ =head2 LABELS 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)> + $plot->add_label($x, $y, $label, %options) # Add multiple labels at once. $plot->add_label( - [$x1, $y1, label => $label1, @options1], - [$x2, $y2, label => $label2, @options2], + [$x1, $y1, $label1, %options1], + [$x2, $y2, $label2, %options2], ... - ); + ); + + # Deprecated way of adding a label with an option instead of the third argument. + $plot->add_label($x, $y, label => $label, %options) Labels can be configured using the following options: @@ -470,8 +533,11 @@ =head2 LABELS =item fontsize -The font size of the label used. This can be one of 'tiny', 'small', 'medium', -'large', or 'giant'. Default: 'medium' +The font size of the label used. This can be one of 'tiny', 'small', +'normalsize', 'large', 'Large', 'huge', or 'Huge' which correspond to the same +named TeX font sizes. Note that this list used to include 'medium' and 'giant' +which still work, but are deprecated. Instead of 'medium' use 'normalsize', and +instead of 'giant' use 'Large'. Default: 'normalsize' =item rotate @@ -499,8 +565,36 @@ =head2 LABELS =back +=head2 POINTS + +Points are really dataset marks (with no associated curve). Note that points +are drawn after all of the other graph objects except labels are drawn. Thus +points will always appear to be on top of everything else (except labels). + +Note that the C, C, C, and C dataset options are +valid for points. The C option is also a valid option that is an alias for +the C dataset option. The C or C options can be used to +change the symbol that is used for the point. By default the symbol is a +C. + + # Add a single point. + $plot->add_point($x1, $y1, color => $color, mark_size => $mark_size); + + # Add multiple points. + $plot->add_point( + [$x1, $y1, color => $color1, mark_size => $mark_size1], + [$x2, $y2, color => $color2, mark_size => $mark_size2], + ... + ); + + # Add a single open point. + $plot->add_point($x1, $y1, color => $color, mark => 'open_circle'); + =head2 STAMPS +Stamps and the C method are deprecated. DO NOT USE THEM. Use the +C or C methods instead. + Stamps are a single point with a mark drawn at the given point. Stamps can be added individually or multiple at once: @@ -513,24 +607,8 @@ =head2 STAMPS ... ); -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], - ... - ); +Adding a stamp is equivalent to creating a dataset with one point with the +exception that stamps are added after all other datasets have been added. =head2 COLORS @@ -564,6 +642,45 @@ =head2 COLORS ... ); +Note that SVG colors can also be used directly by name without being defined via +the C method. See section 4.3 of the +L +documentation for a list of available color names. + +=head2 EXTRA CODE + +Additional JavaScript and TikZ code may be added to draw elements that are not +provided for by this macro and its underlying modules. To add JavaScript code +set the C key on the C<$plot> object, and to add TikZ code set +the C key on the C<$plot> object. The JavaScript code will have +access to the C object via the variable C, and will be +inserted after all of the other code generated by this macro, and before the +C call is executed. The TikZ code will be inserted +after all of the other code generated by this macro, and before the pgfplots +C environment is ended. + +Note that if one of these is used, then both should be used to ensure that both +the JavaScript plot image (used in HTML) and the TikZ plot image (used in +hardcopy) are the same (or at least as close as possible). + +For example, + + $plot = Plot(); + + $plot->{extra_js_code} = << 'END_JS_CODE'; + board.create( + 'line', + [[0, 0], [1, 1]], + { straightLast: false, straightFirst: false, color: 'blue' } + ); + END_JS_CODE + + $plot->{extra_tikz_code} = "\draw[line width = 2pt, blue] (axis cs: 0, 0) -- (axis cs: 1, 1);"; + +Note that the above code is not an actual example that should be used as those +lines could be created using this macro directly. It is only included here to +demonstrate how to use these options. + =head1 TIKZ DEBUGGING When using Tikz output, the pgfplots code used to create the plot is stored in C<< $plot->{tikzCode} >>, From d26549e1d6a377dbfd53e1712d1644ebc465a50d Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Tue, 4 Nov 2025 19:57:17 -0600 Subject: [PATCH 03/10] Optimize the TikZ format when a draw and fill occur separately. This is accomplished using the spath3 TikZ library. To make this work all paths need to be named, and if a draw and fill are done separately, then the fill just uses the path from the draw so it does not need to be recomputed. Additionally refactor multipaths to make the TikZ format much more efficient, as well as to make multipaths more versatile. Both the TikZ and JSXGraph formats are done differently now. They both join the paths in a completely different way that does not use the x transform (so the `function_string` x transform code is now never used in fact). Instead in TikZ the spath3 TikZ library is used and the paths are drawn individually, and then concatenated. For the JSXGraph format the paths are created individually and their data points concatenated to form a single curve. The result allows for more versatility since now paths do not need to end where the next path starts. The paths are connected by a line segment if needed. For JSXGraph this just works with the concatenation approach. For TikZ this has to be added. Note this actually happened before with the previous TikZ implementation, but not for the JSXGraph implementation. The most important thing is that with this implementation the time that it takes for TeX to run for multipaths is greatly reduced. For the example in the POD and the current TikZ code it takes about 3 seconds for TikZ to run, and the CPU usage is quite high. If the fill and draw are on different layers so that the fill and draw occur separately, it takes even longer. With the new code it takes about 1 second for either case and the CPU usage is much less. One reason for this is that the number of steps needed with the new approach (which is now per curve) is much less. Previously 500 steps were used (by default) for the entire curve. Now 30 (by default) are used for each curve. Note that this can now be optimized and steps set per curve. There is also a new `cycle` option for multipaths. If `cycly => 1` is set for a `multipath`, then a line segment will be inserted from the end of the last path to the start of the first in the case that the last path does not end at the start of the first, thus closing the path. --- lib/Plots/JSXGraph.pm | 74 +++++++++++++++++-------- lib/Plots/Plot.pm | 6 +- lib/Plots/Tikz.pm | 125 +++++++++++++++++++++++++++--------------- macros/graph/plots.pl | 55 +++++++++++++++---- 4 files changed, 180 insertions(+), 80 deletions(-) diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index af1248a664..0d0296e3f7 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -390,37 +390,65 @@ sub add_multipath { my ($self, $data) = @_; my @paths = @{ $data->{paths} }; - my $n = scalar(@paths); my $var = $data->{function}{var}; my $curve_name = $data->style('name'); warn 'Duplicate plot name detected. This will most likely cause issues. Make sure that all names used are unique.' if $curve_name && $self->{names}{$curve_name}; $self->{names}{$curve_name} = 1 if $curve_name; my ($plotOptions, $fillOptions) = $self->get_options($data); - my $jsFunctionx = 'function (x){'; - my $jsFunctiony = 'function (x){'; + + my $count = 0; + unless ($curve_name) { + ++$count while ($self->{names}{"_plots_internal_$count"}); + $curve_name = "_plots_internal_$count"; + $self->{names}{$curve_name} = 1; + } + + $count = 0; + ++$count while ($self->{names}{"${curve_name}_$count"}); + my $curve_parts_name = "${curve_name}_$count"; + $self->{names}{$curve_parts_name} = 1; + + $self->{JS} .= "const $curve_parts_name = [\n"; + + my $cycle = $data->style('cycle'); + my ($start_x, $start_y) = ('', ''); for (0 .. $#paths) { my $path = $paths[$_]; - my $a = $_ / $n; - my $b = ($_ + 1) / $n; - my $tmin = $path->{tmin}; - my $tmax = $path->{tmax}; - my $m = ($tmax - $tmin) / ($b - $a); - my $tmp = $a < 0 ? 'x+' . (-$a) : "x-$a"; - my $t = $m < 0 ? "($tmin$m*($tmp))" : "($tmin+$m*($tmp))"; - - my $xfunction = $data->function_string($path->{Fx}, 'js', $var, undef, $t); - my $yfunction = $data->function_string($path->{Fy}, 'js', $var, undef, $t); - $jsFunctionx .= "if(x<=$b){return $xfunction;}"; - $jsFunctiony .= "if(x<=$b){return $yfunction;}"; + + ($start_x, $start_y) = + (', ' . $path->{Fx}->eval($var => $path->{tmin}), ', ' . $path->{Fy}->eval($var => $path->{tmin})) + if $cycle && $_ == 0; + + my $xfunction = $data->function_string($path->{Fx}, 'js', $var); + my $yfunction = $data->function_string($path->{Fy}, 'js', $var); + + $self->{JS} .= + "board.create('curve', " + . "[(x) => $xfunction, (x) => $yfunction, $path->{tmin}, $path->{tmax}], { visible: false }),\n"; } - $jsFunctionx .= 'return 0;}'; - $jsFunctiony .= 'return 0;}'; - $self->{JS} .= "const curve_${curve_name} = " if $curve_name; - $self->{JS} .= "board.create('curve', [$jsFunctionx, $jsFunctiony, 0, 1], $plotOptions);" if $plotOptions; - $self->{JS} .= "board.create('curve', [$jsFunctionx, $jsFunctiony, 0, 1], $fillOptions);" if $fillOptions; + $self->{JS} .= "];\n"; + + if ($plotOptions) { + $self->{JS} .= <<~ "END_JS"; + const curve_$curve_name = board.create('curve', [[], []], $plotOptions); + curve_$curve_name.updateDataArray = function () { + this.dataX = [].concat(...$curve_parts_name.map((c) => c.points.map((p) => p.usrCoords[1]))$start_x); + this.dataY = [].concat(...$curve_parts_name.map((c) => c.points.map((p) => p.usrCoords[2]))$start_y); + }; + END_JS + } + if ($fillOptions) { + $self->{JS} .= <<~ "END_JS"; + const fill_$curve_name = board.create('curve', [[], []], $fillOptions); + fill_$curve_name.updateDataArray = function () { + this.dataX = [].concat(...$curve_parts_name.map((c) => c.points.map((p) => p.usrCoords[1]))); + this.dataY = [].concat(...$curve_parts_name.map((c) => c.points.map((p) => p.usrCoords[2]))); + }; + END_JS + } return; } @@ -530,8 +558,8 @@ sub add_circle { my $r = $data->style('radius'); my ($circleOptions, $fillOptions) = $self->get_options($data); - $self->{JS} .= "board.create('circle', [[$x, $y], $r], $circleOptions);"; - $self->{JS} .= "board.create('circle', [[$x, $y], $r], $fillOptions);" if $fillOptions; + $self->{JS} .= "board.create('circle', [[$x, $y], $r], $circleOptions);" if $circleOptions; + $self->{JS} .= "board.create('circle', [[$x, $y], $r], $fillOptions);" if $fillOptions; return; } @@ -547,7 +575,7 @@ sub add_arc { radiusPoint => { visible => 0 }, ); - $self->{JS} .= "board.create('arc', [[$x1, $y1], [$x2, $y2], [$x3, $y3]], $arcOptions);"; + $self->{JS} .= "board.create('arc', [[$x1, $y1], [$x2, $y2], [$x3, $y3]], $arcOptions);" if $arcOptions; $self->{JS} .= "board.create('arc', [[$x1, $y1], [$x2, $y2], [$x3, $y3]], $fillOptions);" if $fillOptions; return; } diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm index 775a06365e..85bbab0d8e 100644 --- a/lib/Plots/Plot.pm +++ b/lib/Plots/Plot.pm @@ -294,15 +294,15 @@ sub add_function { sub add_multipath { my ($self, $paths, $var, %options) = @_; my $data = Plots::Data->new(name => 'multipath'); - my $steps = 100 * @$paths; # Steps set high to help Tikz deal with boundaries of paths. - $steps = delete $options{steps} if $options{steps}; + my $steps = (delete $options{steps}) || 30; $data->{context} = $self->context; $data->{paths} = [ map { { Fx => $data->get_math_object($_->[0], $var), Fy => $data->get_math_object($_->[1], $var), tmin => $data->str_to_real($_->[2]), - tmax => $data->str_to_real($_->[3]) + tmax => $data->str_to_real($_->[3]), + @$_[ 4 .. $#$_ ] } } @$paths ]; $data->{function} = { var => $var, steps => $steps }; diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 02cedace32..a3276f197e 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -19,7 +19,7 @@ sub new { $image->svgMethod(eval('$main::envir{latexImageSVGMethod}') // 'dvisvgm'); $image->convertOptions(eval('$main::envir{latexImageConvertOptions}') // { input => {}, output => {} }); $image->ext($plots->ext); - $image->tikzLibraries('arrows.meta,plotmarks,calc'); + $image->tikzLibraries('arrows.meta,plotmarks,calc,spath3'); $image->texPackages(['pgfplots']); # Set the pgfplots compatibility, add the pgfplots fillbetween library, define a save @@ -458,18 +458,8 @@ sub get_options { my $fill_color = $data->style('fill_color') || $data->style('color') || 'default_color'; my $fill_opacity = $data->style('fill_opacity') || 0.5; push(@drawOptions, "fill=$fill_color", "fill opacity=$fill_opacity"); - } - - my $name = $data->style('name'); - if ($name) { - warn 'Duplicate plot name detected. This will most likely cause issues. ' - . 'Make sure that all names used are unique.' - if $self->{names}{$name}; - $self->{names}{$name} = 1; - # This forces the curve to be inserted invisibly if it has been named, - # but the curve would otherwise not be drawn. - push(@drawOptions, 'draw=none') if !@drawOptions; - push(@drawOptions, "name path=$name"); + } elsif (!@drawOptions) { + push(@drawOptions, 'draw=none'); } push(@drawOptions, $tikz_options) if $tikz_options; @@ -539,6 +529,19 @@ sub draw { next; } + my $curve_name = $data->style('name'); + warn 'Duplicate plot name detected. This will most likely cause issues. ' + . 'Make sure that all names used are unique.' + if $curve_name && $self->{names}{$curve_name}; + $self->{names}{$curve_name} = 1 if $curve_name; + + my $count = 0; + unless ($curve_name) { + ++$count while ($self->{names}{"_plots_internal_$count"}); + $curve_name = "_plots_internal_$count"; + $self->{names}{$curve_name} = 1; + } + my $fill = $data->style('fill') || 'none'; my $fill_color = $data->style('fill_color') || $data->style('color') || 'default_color'; $tikzCode .= $self->get_color($fill_color) unless $fill eq 'none'; @@ -549,11 +552,12 @@ sub draw { my $x = $data->x(0); my $y = $data->y(0); my $r = $data->style('radius'); - $tikzCode .= $self->draw_on_layer("\\fill[$fill_options->[0]] (axis cs:$x,$y) circle[radius=$r];\n", - $fill_options->[1]) - if $fill_options; - $tikzCode .= $self->draw_on_layer("\\draw[$draw_options->[0]] (axis cs:$x,$y) circle[radius=$r];\n", + $tikzCode .= $self->draw_on_layer( + "\\draw[name path=$curve_name, $draw_options->[0]] (axis cs:$x,$y) circle[radius=$r];\n", $draw_options->[1]); + $tikzCode .= + $self->draw_on_layer("\\fill[$fill_options->[0]] [spath/use=$curve_name];\n", $fill_options->[1]) + if $fill_options; next; } if ($data->name eq 'arc') { @@ -566,20 +570,19 @@ sub draw { $theta1 += 360 if $theta1 < 0; $theta2 += 360 if $theta2 < 0; $tikzCode .= $self->draw_on_layer( - "\\fill[$fill_options->[0]] (axis cs:$x2,$y2) " - . "arc[start angle=$theta1, end angle=$theta2, radius = $r];\n", - $fill_options->[1] - ) if $fill_options; - $tikzCode .= $self->draw_on_layer( - "\\draw[$draw_options->[0]] (axis cs:$x2,$y2) " + "\\draw[name path=$curve_name, $draw_options->[0]] (axis cs:$x2,$y2) " . "arc[start angle=$theta1, end angle=$theta2, radius = $r];\n", $draw_options->[1] ); + $tikzCode .= + $self->draw_on_layer("\\fill[$fill_options->[0]] [spath/use=$curve_name];\n", $fill_options->[1]) + if $fill_options; next; } my $plot; my $plot_options = ''; + if ($data->name eq 'function') { my $f = $data->{function}; if (ref($f->{Fx}) ne 'CODE' && $f->{xvar} eq $f->{Fx}->string) { @@ -599,43 +602,79 @@ sub draw { $plot = "({$xfunction}, {$yfunction})"; } } - } - if ($data->name eq 'multipath') { + } elsif ($data->name eq 'multipath') { my $var = $data->{function}{var}; my @paths = @{ $data->{paths} }; - my $n = scalar(@paths); my @tikzFunctionx; my @tikzFunctiony; + + # This saves the internal path names and the endpoints of the paths. The endpoints are used to determine if + # the paths meet at the endpoints. If the end of one path is not at the same place that the next path + # starts, then the line segment from the first path end to the next path start is inserted. + my @pathData; + + my $count = 0; + for (0 .. $#paths) { my $path = $paths[$_]; - my $a = $_ / $n; - my $b = ($_ + 1) / $n; - my $tmin = $path->{tmin}; - my $tmax = $path->{tmax}; - my $m = ($tmax - $tmin) / ($b - $a); - my $tmp = $a < 0 ? 'x+' . (-$a) : "x-$a"; - my $t = $m < 0 ? "($tmin$m*($tmp))" : "($tmin+$m*($tmp))"; - - my $xfunction = $data->function_string($path->{Fx}, 'PGF', $var, undef, $t); - my $yfunction = $data->function_string($path->{Fy}, 'PGF', $var, undef, $t); - my $last = $_ == $#paths ? '=' : ''; - push(@tikzFunctionx, "(x>=$a)*(x<$last$b)*($xfunction)"); - push(@tikzFunctiony, "(x>=$a)*(x<$last$b)*($yfunction)"); + + my $xfunction = $data->function_string($path->{Fx}, 'PGF', $var); + my $yfunction = $data->function_string($path->{Fy}, 'PGF', $var); + + ++$count while $self->{names}{"${curve_name}_$count"}; + push( + @pathData, + [ + "${curve_name}_$count", + $path->{Fx}->eval($var => $path->{tmin}), + $path->{Fy}->eval($var => $path->{tmin}), + $path->{Fx}->eval($var => $path->{tmax}), + $path->{Fy}->eval($var => $path->{tmax}) + ] + ); + $self->{names}{ $pathData[-1][0] } = 1; + + my $steps = $path->{steps} // $data->{function}{steps}; + + $tikzCode .= + "\\addplot[name path=$pathData[-1][0], draw=none, domain=$path->{tmin}:$path->{tmax}, " + . "samples=$steps] ({$xfunction}, {$yfunction});\n"; } - $plot_options .= ", mark=none, domain=0:1, samples=$data->{function}{steps}"; - $plot = "\n({" . join("\n+", @tikzFunctionx) . "},\n{" . join("\n+", @tikzFunctiony) . '})'; + + $tikzCode .= "\\path[name path=$curve_name] " . join( + ' ', + map { + ( + $_ == 0 || ($pathData[ $_ - 1 ][3] == $pathData[$_][1] + && $pathData[ $_ - 1 ][4] == $pathData[$_][2]) + ? '' + : "-- (spath cs:$pathData[$_ - 1][0] 1) -- (spath cs:$pathData[$_][0] 0) " + ) + . "[spath/append no move=$pathData[$_][0]]" + } 0 .. $#pathData + ) . ($data->style('cycle') ? '-- cycle' : '') . ";\n"; + + $plot = 'skip'; + $tikzCode .= + $self->draw_on_layer("\\draw[$draw_options->[0], spath/use=$curve_name];\n", $draw_options->[1]); } + unless ($plot) { $data->gen_data; $plot = 'coordinates {' . join(' ', map { '(' . $data->x($_) . ',' . $data->y($_) . ')'; } (0 .. $data->size - 1)) . '}'; } - $tikzCode .= $self->draw_on_layer("\\addplot[$fill_options->[0]$plot_options] $plot;\n", $fill_options->[1]) + + # 'skip' is a special value of $plot for a multipath which has already been drawn. + $tikzCode .= $self->draw_on_layer("\\addplot[name path=$curve_name, $draw_options->[0]$plot_options] $plot;\n", + $draw_options->[1]) + unless $plot eq 'skip'; + $tikzCode .= $self->draw_on_layer("\\fill[$fill_options->[0]] [spath/use=$curve_name];\n", $fill_options->[1]) if $fill_options; - $tikzCode .= $self->draw_on_layer("\\addplot[$draw_options->[0]$plot_options] $plot;\n", $draw_options->[1]); unless ($fill eq 'none' || $fill eq 'self') { if ($self->{names}{$fill}) { + # Make sure this is the name from the data style attribute, and not an internal name. my $name = $data->style('name'); if ($name) { my $opacity = $data->style('fill_opacity') || 0.5; diff --git a/macros/graph/plots.pl b/macros/graph/plots.pl index a41f2e6c52..6beeef99fc 100644 --- a/macros/graph/plots.pl +++ b/macros/graph/plots.pl @@ -197,26 +197,34 @@ =head2 PLOT FUNCTIONS =head2 PLOT MULTIPATH FUNCTIONS -A multipath function is defined using multiple parametric paths pieced together into into a single -curve, whose primary use is to create a closed region to be filled using multiple boundaries. -This is done by providing a list of parametric functions, the name of the parameter, and a list -of options. +A multipath function is defined using multiple parametric paths pieced together +into into a single curve, whose primary use is to create a closed region to be +filled using multiple boundaries. This is done by providing a list of +parametric functions, the name of the parameter, and a list of options. $plot->add_multipath( [ - [ $function_x1, $function_y1, $min1, $max1 ], - [ $function_x2, $function_y2, $min2, $max2 ], + [ $function_x1, $function_y1, $min1, $max1, %path_options ], + [ $function_x2, $function_y2, $min2, $max2, %path_options ], ... ], $variable, %options ); -The paths have to be listed in the order they are followed, but the minimum/maximum values -of the parameter can match the parametrization. The following example creates a sector of -radius 5 between pi/4 and 3pi/4, by first drawing the line (0,0) to (5sqrt(2),5/sqrt(2)), -then the arc of the circle of radius 5 from pi/4 to 3pi/4, followed by the final line from -(-5sqrt(2), 5sqrt(2)) back to the origin. +Note that C<%path_options> can be specified for each path. At this point, the +only supported individual path option is C, if specified, then that +number of steps will be used for that path in the TikZ format. If not specified +the number of steps for the multipath will be used. That defaults to 30, but can +be changed by passing the C option in the general C<%options> for the +multipath. + +The paths have to be listed in the order they are followed, but the +minimum/maximum values of the parameter can match the parametrization. The +following example creates a sector of radius 5 between pi/4 and 3pi/4, by first +drawing the line from (0,0) to (5sqrt(2),5/sqrt(2)), then the arc of the circle +of radius 5 from pi/4 to 3pi/4, followed by the final line from (-5sqrt(2), +5sqrt(2)) back to the origin. $plot->add_multipath( [ @@ -229,6 +237,31 @@ =head2 PLOT MULTIPATH FUNCTIONS fill => 'self', ); +Note that the ending point of one path does not need to be the same as the +starting point of the next. In this case a line segment will connect the end of +the first path to the start of the next. Additionally, if C<< cycle => 1 >> is +added to the C<%options> for the multipath, and the last path does not end where +the first path starts, then a line segment will connect the end of the last path +to the start of the first path. For example, the following path draws the top +half of a circle of radius two centered at the point (0, 2), followed by the +line segment from (-2, 0) to (2, 0). The line segment from (-2, 2) to (-2, 0) is +implicitly added to connect the end of the first path to the beginning of the +second path. The cycle option is added to close the path with the line segment +from (2, 0) to (2, 2). Note that drawing of the line is optimized by using only +2 steps, and the fill region is drawn on the "axis background" layer. + + $plot->add_multipath( + [ + [ '2 cos(t) + 5', '2 sin(t) - 5', '0', 'pi' ], + [ 't', '-8', '3', '7', steps => 2 ] + ], + 't', + color => 'green', + fill => 'self', + fill_layer => 'axis background', + cycle => 1 + ); + =head2 PLOT CIRCLES Circles can be added to the plot by specifying its center and radius using the From 71fe210f3d71efcad5894404ba16edb6b7d70fda Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Thu, 6 Nov 2025 07:05:37 -0600 Subject: [PATCH 04/10] Implement the `continue`, `continue_left`, and `continue_right` options in the TikZ format. This was an inconsistency between the two formats. Basically, the JSXGraph format did not honor the function max if `continue` or `continue_right` was set, but the TikZ format did. This could result in a function graph continuing to the right in the JSXGraph format, but not in the TikZ format (assuming the board bounds go further to the right). This just makes the TikZ output use the axes max instead of the function max in this case. Of course if `continue` and `continue_left` are set then the axes min is used instead of the function min. --- lib/Plots/Tikz.pm | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index a3276f197e..6da6314397 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -589,8 +589,11 @@ sub draw { my $function = $data->function_string($f->{Fy}, 'PGF', $f->{xvar}); if ($function ne '') { $data->update_min_max; + my ($axes_xmin, undef, $axes_xmax) = $plots->axes->bounds; + my $min = $data->style('continue') || $data->style('continue_left') ? $axes_xmin : $f->{xmin}; + my $max = $data->style('continue') || $data->style('continue_right') ? $axes_xmax : $f->{xmax}; $plot_options .= ", data cs=polar" if $data->style('polar'); - $plot_options .= ", domain=$f->{xmin}:$f->{xmax}, samples=$f->{xsteps}"; + $plot_options .= ", domain=$min:$max, samples=$f->{xsteps}"; $plot = "{$function}"; } } else { From 78813198bff9207fa154a0f4d092485f5c890557 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Tue, 11 Nov 2025 06:56:22 -0600 Subject: [PATCH 05/10] Fix issues with arcs. Adding 360 degrees to the computed theta1 if theta1 is negative and independently doing the same for theta2 is incorrect. For example, if theta1 is negative, but theta2 is positive, then that can result in theta1 being greater than theta2 and give an unintended result, and is inconsistent with the JSXGraph result in this case. What should happen is that 360 be added to theta2 if theta2 is less than or equal to theta1, and theta1 should never be modified. This gives consistent results with the JSXGraph arc in all cases (except the case below, and that is made consistent with the JSXGraph change in this commit). JSXGraph is not capable of drawing a 360 degree arc (i.e., the case that an arc starts and ends at the same point). So if the start and end point are the same, then move the end point back around the circle a tiny amount so that JSXGraph will draw the entire circle. --- lib/Plots/JSXGraph.pm | 8 ++++++++ lib/Plots/Tikz.pm | 3 +-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index 0d0296e3f7..a57450e775 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -575,6 +575,14 @@ sub add_arc { radiusPoint => { visible => 0 }, ); + # JSXGraph arcs cannot make a 360 degree revolution. So in the case that the start and end point are the same, + # move the end point back around the circle a tiny amount. + if ($x2 == $x3 && $y2 == $y3) { + my $theta = atan2($y2 - $y1, $x2 - $x1) + 2 * 3.14159265358979 - 0.0001; + $x3 = $x1 + cos($theta); + $y3 = $y1 + sin($theta); + } + $self->{JS} .= "board.create('arc', [[$x1, $y1], [$x2, $y2], [$x3, $y3]], $arcOptions);" if $arcOptions; $self->{JS} .= "board.create('arc', [[$x1, $y1], [$x2, $y2], [$x3, $y3]], $fillOptions);" if $fillOptions; return; diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 6da6314397..00379d42ee 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -567,8 +567,7 @@ sub draw { my $r = sqrt(($x2 - $x1)**2 + ($y2 - $y1)**2); my $theta1 = 180 * atan2($y2 - $y1, $x2 - $x1) / 3.14159265358979; my $theta2 = 180 * atan2($y3 - $y1, $x3 - $x1) / 3.14159265358979; - $theta1 += 360 if $theta1 < 0; - $theta2 += 360 if $theta2 < 0; + $theta2 += 360 if $theta2 <= $theta1; $tikzCode .= $self->draw_on_layer( "\\draw[name path=$curve_name, $draw_options->[0]] (axis cs:$x2,$y2) " . "arc[start angle=$theta1, end angle=$theta2, radius = $r];\n", From bb4eec7455f76bff8c12221535d5afc631185fa7 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Thu, 13 Nov 2025 06:19:11 -0600 Subject: [PATCH 06/10] Some label positioning improvements. There are two new options for positioning labels, `anchor` and `padding`. The `padding` option makes it possible for the author to easily set the padding for the label. This is css padding in pixels for the JSXGraph format, and the value of the node `inner sep` in points for the TikZ format. The `anchor` option is an alternate positioning approach to the current `h_align` and `v_align` approach. See the POD documentation for a (hopefully) good explanation of this option. Basically, in the TikZ format this is the value for the node `anchor` option in degrees. Since JSXGraph doesn't provide such an option, this had to be implemented using a transformation. This is useful for positioning labels for angles. --- htdocs/js/Plots/plots.js | 190 ++++++++++++++++++++++++--------------- lib/Plots/JSXGraph.pm | 18 ++-- lib/Plots/Tikz.pm | 8 +- macros/graph/plots.pl | 15 ++++ 4 files changed, 150 insertions(+), 81 deletions(-) diff --git a/htdocs/js/Plots/plots.js b/htdocs/js/Plots/plots.js index df2624b035..46cdb16934 100644 --- a/htdocs/js/Plots/plots.js +++ b/htdocs/js/Plots/plots.js @@ -53,83 +53,129 @@ const PGplots = { board.containerObj.after(descriptionSpan); board.containerObj.setAttribute('aria-describedby', descriptionSpan.id); - // Convert a decimal number into a fraction or mixed number. This is basically the JXG.toFraction method - // except that the "mixed" parameter is added, and it returns an improper fraction if mixed is false. - const toFraction = (x, useTeX, mixed, order) => { - const arr = JXG.Math.decToFraction(x, order); - - if (arr[1] === 0 && arr[2] === 0) { - return '0'; - } else { - let str = ''; - // Sign - if (arr[0] < 0) str += '-'; - if (arr[2] === 0) { - // Integer - str += arr[1]; - } else if (!(arr[2] === 1 && arr[3] === 1)) { - // Proper fraction - if (mixed) { - if (arr[1] !== 0) str += arr[1] + ' '; - if (useTeX) str += `\\frac{${arr[2]}}{${arr[3]}}`; - else str += `${arr[2]}/${arr[3]}`; + // This object is passed to the plotContents method as its second argument and exposes these methods (and + // potentially other things in the future) to the code that is called in that method. So the JavaScript code + // generated in JSXGraph.pm can use these methods. + const plot = { + // Convert a decimal number into a fraction or mixed number. This is basically the JXG.toFraction method + // except that the "mixed" parameter is added, and it returns an improper fraction if mixed is false. + toFraction(x, useTeX, mixed, order) { + const arr = JXG.Math.decToFraction(x, order); + + if (arr[1] === 0 && arr[2] === 0) { + return '0'; + } else { + let str = ''; + // Sign + if (arr[0] < 0) str += '-'; + if (arr[2] === 0) { + // Integer + str += arr[1]; + } else if (!(arr[2] === 1 && arr[3] === 1)) { + // Proper fraction + if (mixed) { + if (arr[1] !== 0) str += arr[1] + ' '; + if (useTeX) str += `\\frac{${arr[2]}}{${arr[3]}}`; + else str += `${arr[2]}/${arr[3]}`; + } else { + if (useTeX) str += `\\frac{${arr[3] * arr[1] + arr[2]}}{${arr[3]}}`; + else str += `${arr[3] * arr[1] + arr[2]}/${arr[3]}`; + } + } + return str; + } + }, + + // Override the default axis generateLabelText method so that 0 is displayed + // using MathJax if the axis is configured to show tick labels using MathJax. + generateLabelText(tick, zero, value) { + if (JXG.exists(value)) return this.formatLabelText(value); + const distance = this.getDistanceFromZero(zero, tick); + return this.formatLabelText(Math.abs(distance) < JXG.Math.eps ? 0 : distance / this.visProp.scale); + }, + + trimTrailingZeros(value) { + if (value.indexOf('.') > -1 && value.endsWith('0')) { + value = value.replace(/0+$/, ''); + // Remove the decimal if it is now at the end. + value = value.replace(/\.$/, ''); + } + return value; + }, + + // Override the formatLabelText method for the axes ticks so that + // better number formats can be used for tick labels. + formatLabelText(value) { + let labelText; + + if (JXG.isNumber(value)) { + if (this.visProp.label.format === 'fraction' || this.visProp.label.format === 'mixed') { + labelText = plot.toFraction( + value, + this.visProp.label.usemathjax, + this.visProp.label.format === 'mixed' + ); + } else if (this.visProp.label.format === 'scinot') { + const [mantissa, exponent] = value.toExponential(this.visProp.digits).toString().split('e'); + labelText = this.visProp.label.usemathjax + ? `${plot.trimTrailingZeros(mantissa)}\\cdot 10^{${exponent}}` + : `${plot.trimTrailingZeros(mantissa)} x 10^${exponent}`; } else { - if (useTeX) str += `\\frac{${arr[3] * arr[1] + arr[2]}}{${arr[3]}}`; - else str += `${arr[3] * arr[1] + arr[2]}/${arr[3]}`; + labelText = plot.trimTrailingZeros(value.toFixed(this.visProp.digits).toString()); } + } else { + labelText = value.toString(); } - return str; - } - }; - // Override the default axis generateLabelText method so that 0 is displayed - // using MathJax if the axis is configured to show tick labels using MathJax. - const generateLabelText = function (tick, zero, value) { - if (JXG.exists(value)) return this.formatLabelText(value); - const distance = this.getDistanceFromZero(zero, tick); - return this.formatLabelText(Math.abs(distance) < JXG.Math.eps ? 0 : distance / this.visProp.scale); - }; + if (this.visProp.scalesymbol.length > 0) { + if (labelText === '1') labelText = this.visProp.scalesymbol; + else if (labelText === '-1') labelText = `-${this.visProp.scalesymbol}`; + else if (labelText !== '0') labelText = labelText + this.visProp.scalesymbol; + } - const trimTrailingZeros = (value) => { - if (value.indexOf('.') > -1 && value.endsWith('0')) { - value = value.replace(/0+$/, ''); - // Remove the decimal if it is now at the end. - value = value.replace(/\.$/, ''); - } - return value; - }; + return this.visProp.label.usemathjax ? `\\(${labelText}\\)` : labelText; + }, + + createLabel(x, y, text, options) { + const anchor = options.angleAnchor; + delete options.angleAnchor; + const rotate = options.rotate; + delete options.rotate; - // Override the formatLabelText method for the axes ticks so that - // better number formats can be used for tick labels. - const formatLabelText = function (value) { - let labelText; - - if (JXG.isNumber(value)) { - if (this.visProp.label.format === 'fraction' || this.visProp.label.format === 'mixed') { - labelText = toFraction( - value, - this.visProp.label.usemathjax, - this.visProp.label.format === 'mixed' + const textElement = board.create('text', [x, y, text], options); + + if (typeof anchor !== 'undefined') { + const cosA = Math.cos((anchor * Math.PI) / 180); + const sinA = Math.sin((anchor * Math.PI) / 180); + + const transform = board.create( + 'transform', + [ + () => { + const [w, h] = textElement.getSize(); + return ( + (w * Math.abs(sinA) > h * Math.abs(cosA) + ? (-h / 2 / Math.abs(sinA)) * cosA + : ((cosA < 0 ? 1 : -1) * w) / 2) / board.unitX + ); + }, + () => { + const [w, h] = textElement.getSize(); + return ( + (w * Math.abs(sinA) > h * Math.abs(cosA) + ? ((sinA < 0 ? 1 : -1) * h) / 2 + : (-w / 2 / Math.abs(cosA)) * sinA) / board.unitY + ); + } + ], + { type: 'translate' } ); - } else if (this.visProp.label.format === 'scinot') { - const [mantissa, exponent] = value.toExponential(this.visProp.digits).toString().split('e'); - labelText = this.visProp.label.usemathjax - ? `${trimTrailingZeros(mantissa)}\\cdot 10^{${exponent}}` - : `${trimTrailingZeros(mantissa)} x 10^${exponent}`; - } else { - labelText = trimTrailingZeros(value.toFixed(this.visProp.digits).toString()); + transform.bindTo(textElement); } - } else { - labelText = value.toString(); - } + if (rotate) textElement.addRotation(rotate); - if (this.visProp.scalesymbol.length > 0) { - if (labelText === '1') labelText = this.visProp.scalesymbol; - else if (labelText === '-1') labelText = `-${this.visProp.scalesymbol}`; - else if (labelText !== '0') labelText = labelText + this.visProp.scalesymbol; + return textElement; } - - return this.visProp.label.usemathjax ? `\\(${labelText}\\)` : labelText; }; board.suspendUpdate(); @@ -329,8 +375,8 @@ const PGplots = { options.xAxis.overrideOptions ?? {} ) ); - xAxis.defaultTicks.generateLabelText = generateLabelText; - xAxis.defaultTicks.formatLabelText = formatLabelText; + xAxis.defaultTicks.generateLabelText = plot.generateLabelText; + xAxis.defaultTicks.formatLabelText = plot.formatLabelText; if (options.xAxis.location !== 'middle') { board.create( @@ -424,8 +470,8 @@ const PGplots = { options.yAxis.overrideOptions ?? {} ) ); - yAxis.defaultTicks.generateLabelText = generateLabelText; - yAxis.defaultTicks.formatLabelText = formatLabelText; + yAxis.defaultTicks.generateLabelText = plot.generateLabelText; + yAxis.defaultTicks.formatLabelText = plot.formatLabelText; if (options.yAxis.location !== 'center') { board.create( @@ -448,7 +494,7 @@ const PGplots = { } } - plotContents(board); + plotContents(board, plot); board.unsuspendUpdate(); diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index a57450e775..2a68f7d3c1 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -149,7 +149,7 @@ sub HTML { (async () => { const id = 'jsxgraph-plot-$self->{name}'; const options = ${\(Mojo::JSON::encode_json($options))}; - const plotContents = (board) => { $self->{JS}$plots->{extra_js_code} }; + const plotContents = (board, plot) => { $self->{JS}$plots->{extra_js_code} }; if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', async () => { await PGplots.plot(id, plotContents, options); }); @@ -633,6 +633,9 @@ sub draw { my $fontsize = $label->style('fontsize') || 'normalsize'; my $h_align = $label->style('h_align') || 'center'; my $v_align = $label->style('v_align') || 'middle'; + my $anchor = $label->style('anchor'); + my $rotate = $label->style('rotate'); + my $padding = $label->style('padding') || 4; my $textOptions = Mojo::JSON::encode_json({ fontSize => { tiny => 8, @@ -646,17 +649,18 @@ sub draw { huge => 20, Huge => 23 }->{$fontsize}, - $label->style('rotate') ? (rotate => $label->style('rotate')) : (), strokeColor => $self->get_color($label->style('color')), - anchorX => $h_align eq 'center' ? 'middle' : $h_align, - anchorY => $v_align, - cssStyle => 'padding: 3px 5px;', - useMathJax => 1, + $anchor ne '' + ? (angleAnchor => $anchor, anchorX => 'middle', anchorY => 'middle') + : (anchorX => $h_align eq 'center' ? 'middle' : $h_align, anchorY => $v_align), + $rotate ? (rotate => $rotate) : (), + cssStyle => "line-height: 1; padding: ${padding}px;", + useMathJax => 1, }); $textOptions = "JXG.merge($textOptions, " . Mojo::JSON::encode_json($label->style('jsx_options')) . ')' if $label->style('jsx_options'); - $self->{JS} .= "board.create('text', [$x, $y, '$str'], $textOptions);"; + $self->{JS} .= "plot.createLabel($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 00379d42ee..101bf4d7a7 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -728,9 +728,12 @@ sub draw { 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 = join(' ', + my $anchor = $label->style('anchor'); + $anchor = join(' ', $v_align eq 'top' ? 'north' : $v_align eq 'bottom' ? 'south' : (), - $h_align eq 'left' ? 'west' : $h_align eq 'right' ? 'east' : ()); + $h_align eq 'left' ? 'west' : $h_align eq 'right' ? 'east' : ()) + if $anchor eq ''; + my $padding = $label->style('padding') || 4; $str = { tiny => '\tiny ', small => '\small ', @@ -746,6 +749,7 @@ sub draw { $tikz_options = $tikz_options ? "$color, $tikz_options" : $color; $tikz_options = "anchor=$anchor, $tikz_options" if $anchor; $tikz_options = "rotate=$rotate, $tikz_options" if $rotate; + $tikz_options = "inner sep=${padding}pt, $tikz_options"; $tikzCode .= $self->get_color($color) . "\\node[$tikz_options] at (axis cs: $x,$y) {$str};\n"; } diff --git a/macros/graph/plots.pl b/macros/graph/plots.pl index 6beeef99fc..9674eedd7b 100644 --- a/macros/graph/plots.pl +++ b/macros/graph/plots.pl @@ -588,6 +588,21 @@ =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 anchor + +The angle in degrees of the label anchor relative to the center of the text. In +other words, the text will be positioned relative to the point on the rectangle +encompassing the label text (including C) where a ray shot from the +text center with the given angle hits the rectangle. This is an alternate method +for positioning the text relative to the label position. If this is set, then +C and C are not used. This is particularly useful for +positioning text when labeling angles. Default: '' + +=item padding + +This is the horizontal and vertical padding applied to the text of the label (in +pixels for the JSXGraph format, and in points for the TikZ format). Default: 4 + =item jsx_options An hash reference of options to pass to JSXGraph text object. From 2755fcfa7e191d210a4e806030a8feaa09903669 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Tue, 11 Nov 2025 05:05:02 -0600 Subject: [PATCH 07/10] Make multipath allow points. --- lib/Plots/JSXGraph.pm | 6 ++++++ lib/Plots/Plot.pm | 18 +++++++++++------- lib/Plots/Tikz.pm | 30 ++++++++++++++++++------------ macros/graph/plots.pl | 31 +++++++++++++++++++------------ 4 files changed, 54 insertions(+), 31 deletions(-) diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index 2a68f7d3c1..c21761f528 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -417,6 +417,12 @@ sub add_multipath { for (0 .. $#paths) { my $path = $paths[$_]; + if (ref $path eq 'ARRAY') { + ($start_x, $start_y) = (', ' . $path->[0], ', ' . $path->[1]) if $cycle && $_ == 0; + $self->{JS} .= "board.create('curve', [[$path->[0]], [$path->[1]]], { visible: false }),\n"; + next; + } + ($start_x, $start_y) = (', ' . $path->{Fx}->eval($var => $path->{tmin}), ', ' . $path->{Fy}->eval($var => $path->{tmin})) if $cycle && $_ == 0; diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm index 85bbab0d8e..b03262739f 100644 --- a/lib/Plots/Plot.pm +++ b/lib/Plots/Plot.pm @@ -297,13 +297,17 @@ sub add_multipath { my $steps = (delete $options{steps}) || 30; $data->{context} = $self->context; $data->{paths} = [ - map { { - Fx => $data->get_math_object($_->[0], $var), - Fy => $data->get_math_object($_->[1], $var), - tmin => $data->str_to_real($_->[2]), - tmax => $data->str_to_real($_->[3]), - @$_[ 4 .. $#$_ ] - } } @$paths + map { + @$_ == 2 + ? [@$_] + : { + Fx => $data->get_math_object($_->[0], $var), + Fy => $data->get_math_object($_->[1], $var), + tmin => $data->str_to_real($_->[2]), + tmax => $data->str_to_real($_->[3]), + @$_[ 4 .. $#$_ ] + } + } @$paths ]; $data->{function} = { var => $var, steps => $steps }; $data->style(color => 'default_color', width => 2, mark_size => 2, %options); diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 101bf4d7a7..05f538e95f 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -620,21 +620,27 @@ sub draw { for (0 .. $#paths) { my $path = $paths[$_]; - my $xfunction = $data->function_string($path->{Fx}, 'PGF', $var); - my $yfunction = $data->function_string($path->{Fy}, 'PGF', $var); - ++$count while $self->{names}{"${curve_name}_$count"}; + push(@pathData, ["${curve_name}_$count"]); + $self->{names}{ $pathData[-1][0] } = 1; + + if (ref $path eq 'ARRAY') { + $tikzCode .= + "\\addplot[name path=$pathData[-1][0], draw=none] coordinates {($path->[0], $path->[1])};\n"; + push(@{ $pathData[-1] }, @$path, @$path); + next; + } + push( - @pathData, - [ - "${curve_name}_$count", - $path->{Fx}->eval($var => $path->{tmin}), - $path->{Fy}->eval($var => $path->{tmin}), - $path->{Fx}->eval($var => $path->{tmax}), - $path->{Fy}->eval($var => $path->{tmax}) - ] + @{ $pathData[-1] }, + $path->{Fx}->eval($var => $path->{tmin}), + $path->{Fy}->eval($var => $path->{tmin}), + $path->{Fx}->eval($var => $path->{tmax}), + $path->{Fy}->eval($var => $path->{tmax}) ); - $self->{names}{ $pathData[-1][0] } = 1; + + my $xfunction = $data->function_string($path->{Fx}, 'PGF', $var); + my $yfunction = $data->function_string($path->{Fy}, 'PGF', $var); my $steps = $path->{steps} // $data->{function}{steps}; diff --git a/macros/graph/plots.pl b/macros/graph/plots.pl index 9674eedd7b..9f44be4526 100644 --- a/macros/graph/plots.pl +++ b/macros/graph/plots.pl @@ -197,15 +197,22 @@ =head2 PLOT FUNCTIONS =head2 PLOT MULTIPATH FUNCTIONS -A multipath function is defined using multiple parametric paths pieced together -into into a single curve, whose primary use is to create a closed region to be -filled using multiple boundaries. This is done by providing a list of -parametric functions, the name of the parameter, and a list of options. +A multipath function is defined using multiple parametric paths and points +pieced together into into a single curve. This is done by providing a list of +parametric functions and points, the name of the parameter, and a list of +options. A parametric function is specified by a reference to an array +containing an x function, a y function, the minimum value for the parameter, and +the maximum value for the parameter, followed by options. A point is specified +by a reference to an array containing the coordinates of the point. One reason +for creating a multipath is to create a closed region to be filled using +multiple boundaries. $plot->add_multipath( [ - [ $function_x1, $function_y1, $min1, $max1, %path_options ], - [ $function_x2, $function_y2, $min2, $max2, %path_options ], + [ $function_x1, $function_y1, $min1, $max1, %path_options1 ], + [ $function_x2, $function_y2, $min2, $max2, %path_options2 ], + [ $point_x1, $point_x2 ] + [ $function_x3, $function_y3, $min3, $max3, %path_options3 ], ... ], $variable, @@ -222,19 +229,19 @@ =head2 PLOT MULTIPATH FUNCTIONS The paths have to be listed in the order they are followed, but the minimum/maximum values of the parameter can match the parametrization. The following example creates a sector of radius 5 between pi/4 and 3pi/4, by first -drawing the line from (0,0) to (5sqrt(2),5/sqrt(2)), then the arc of the circle -of radius 5 from pi/4 to 3pi/4, followed by the final line from (-5sqrt(2), -5sqrt(2)) back to the origin. +drawing the arc of the circle of radius 5 from pi/4 to 3pi/4, followed by the +line from (-5 sqrt(2), 5 sqrt(2)) to the origin, and then drawing the line from +the origin to (5 sqrt(2), 5 sqrt(2)). $plot->add_multipath( [ - [ 't', 't', 0, '5/sqrt(2)' ], - [ '5cos(t)', '5sin(t)', 'pi/4', '3pi/4' ], - [ '-t', 't', '5/sqrt(2)', 0 ], + [ '5cos(t)', '5sin(t)', 'pi/4', '3pi/4' ], + [ 0, 0 ], ], 't', color => 'green', fill => 'self', + cycle => 1 ); Note that the ending point of one path does not need to be the same as the From 529ce1f647338cab5642f8b1624199d15e488f4f Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Thu, 20 Nov 2025 11:53:42 -0600 Subject: [PATCH 08/10] Add fill_min_y and fill_max_y options. These options allow restricting the fill region in the y range when filling between curves. Furthermore, these options can be combined with the fill_min and fill_max options to restrict the fill region to a rectangle. --- lib/Plots/JSXGraph.pm | 14 ++++++++++++++ lib/Plots/Tikz.pm | 19 ++++++++++++++----- macros/graph/plots.pl | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index c21761f528..2d7045f3a8 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -326,6 +326,8 @@ sub add_curve { if ($curve_name) { my $fill_min = $data->str_to_real($data->style('fill_min')); my $fill_max = $data->str_to_real($data->style('fill_max')); + my $fill_min_y = $data->str_to_real($data->style('fill_min_y')); + my $fill_max_y = $data->str_to_real($data->style('fill_max_y')); my $fill_layer = $self->get_layer($data, 1) // $self->get_layer($data); my $fillOptions = Mojo::JSON::encode_json({ strokeWidth => 0, @@ -361,12 +363,24 @@ sub add_curve { ".filter(p => {" . "return p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false" . "})"; } + if ($fill_min_y ne '' && $fill_max_y ne '') { + $self->{JS} .= + ".filter(p => {" + . "return p.usrCoords[2] >= $fill_min_y && p.usrCoords[2] <= $fill_max_y ? true : false" + . "})"; + } $self->{JS} .= ";const points2 = curve_${fill}.points"; if ($fill_min ne '' && $fill_max ne '') { $self->{JS} .= ".filter(p => {" . "return p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false" . "})"; } + if ($fill_min_y ne '' && $fill_max_y ne '') { + $self->{JS} .= + ".filter(p => {" + . "return p.usrCoords[2] >= $fill_min_y && p.usrCoords[2] <= $fill_max_y ? true : false" + . "})"; + } $self->{JS} .= ";this.dataX = points1.map( p => p.usrCoords[1] ).concat(" . "points2.map( p => p.usrCoords[1] ).reverse());" diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 05f538e95f..591dfe2851 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -685,18 +685,27 @@ sub draw { # Make sure this is the name from the data style attribute, and not an internal name. my $name = $data->style('name'); if ($name) { - my $opacity = $data->style('fill_opacity') || 0.5; - my $fill_min = $data->style('fill_min'); - my $fill_max = $data->style('fill_max'); + my $opacity = $data->style('fill_opacity') || 0.5; + my $fill_min = $data->style('fill_min'); + my $fill_max = $data->style('fill_max'); + my $fill_min_y = $data->style('fill_min_y'); + my $fill_max_y = $data->style('fill_max_y'); + my $fill_reverse = $data->style('fill_reverse'); my $fill_range = - $fill_min ne '' && $fill_max ne '' ? ", soft clip={domain=$fill_min:$fill_max}" : ''; + $fill_min ne '' && $fill_max ne '' && $fill_min_y ne '' && $fill_max_y ne '' + ? ", soft clip={($fill_min, $fill_min_y) rectangle ($fill_max, $fill_max_y)}" + : $fill_min ne '' && $fill_max ne '' ? ", soft clip={domain=$fill_min:$fill_max}" + : $fill_min_y ne '' && $fill_max_y ne '' ? ", soft clip={domain y=$fill_min_y:$fill_max_y}" + : ''; my $fill_layer = $data->style('fill_layer') || $layer; + my $reverse = $fill_reverse eq '' ? '' : $fill_reverse ? ', reverse' : 'reverse=false'; $tikzCode .= "\\begin{scope}[/tikz/fill between/on layer=$fill_layer]\\begin{pgfonlayer}{$fill_layer}" . "\\clip\\axisclippath;\n" if $fill_layer; $tikzCode .= - "\\addplot[$fill_color, fill opacity=$opacity] fill between[of=$name and $fill$fill_range];\n"; + "\\addplot[$fill_color, fill opacity=$opacity] " + . "fill between[of=$name and $fill$fill_range$reverse];\n"; $tikzCode .= "\\end{pgfonlayer}\\end{scope}\n" if $fill_layer; } else { warn q{Unable to create fill. Missing 'name' attribute.}; diff --git a/macros/graph/plots.pl b/macros/graph/plots.pl index 9f44be4526..de245bd660 100644 --- a/macros/graph/plots.pl +++ b/macros/graph/plots.pl @@ -473,6 +473,23 @@ =head2 DATASET OPTIONS fill_opacity => 0.2, ); +The following fills the area between the two curves x = 4 - y^2 and x = y^2 - 4, +and only fills in the area between y=-2 and y=2: + + $plot->add_function(['4 - y^2', 'y'], 'y', -3, 3, + color => 'blue', + name => 'A' + ); + $plot->add_function(['y^2 - 4', 'y'] 'y', -3, 3, + color => 'blue', + name => 'B', + fill => 'A', + fill_min_y => -2, + fill_max_y => 2, + fill_color => 'green', + fill_opacity => 0.2, + ); + =item fill_color The color used when filling the region. Default: C @@ -487,6 +504,30 @@ =head2 DATASET OPTIONS not defined, then the fill will use the full domain of the function. Default: undefined +=item fill_min_y, fill_max_y + +The minimum and maximum y-values to fill between. If either of these are +not defined, then the fill will use the full y range of the function. +Default: undefined + +Note that fill_min, fill_max, fill_min_y, and fill_max_y can be defined, and +this will result in the region filled being restricted to the rectangle defined +by those ranges. + +=item fill_reverse + +This should only be used if the TikZ output is filling the wrong region when +filling between two curves. By default the pgfplots fillbetween library +attempts to autodetect if one of the curves needs to be reversed in order to +obtain the fill region between the curves. However, the heuristic used for the +autodetection is sometimes wrong. Particularly when filling between two +functions x of y using the C and C options. In that case +set this option to either 0 or 1. If set to 1, then a reversal of one of the +curves is forced. If set to 0, then no reversal will occur. If this is unset, +then the pgfplots fillbetween library will attempt to autodetect if a reversal +is needed. If the correct region is being filled as compared to the JSXGraph +output, then leave this unset. Default: undefined + =item layer The layer to draw on. Available layers are "axis background", "axis grid", From c029b8b8a7e5ec8c2b0360932d3842200ebf8f85 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Sat, 22 Nov 2025 06:12:09 -0600 Subject: [PATCH 09/10] Make the axes arrows configurable per axis. There are times that you don't want the arrows going in both directions on both axes. For instance, if the x-axis doesn't go to the left, but the y-axis does go both up and down. --- htdocs/js/Plots/plots.js | 4 ++-- lib/Plots/Axes.pm | 20 ++++++++++---------- lib/Plots/JSXGraph.pm | 7 +++---- lib/Plots/Tikz.pm | 11 ++++++----- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/htdocs/js/Plots/plots.js b/htdocs/js/Plots/plots.js index 46cdb16934..ca1d5532ae 100644 --- a/htdocs/js/Plots/plots.js +++ b/htdocs/js/Plots/plots.js @@ -326,7 +326,7 @@ const PGplots = { ? 'sticky' : 'static' : 'fixed', - firstArrow: options.axesArrowsBoth ? { size: 7 } : false, + firstArrow: options.xAxis.arrowsBoth ? { size: 7 } : false, lastArrow: { size: 7 }, highlight: false, straightFirst: options.board?.showNavigation ? true : false, @@ -420,7 +420,7 @@ const PGplots = { ? 'sticky' : 'static' : 'fixed', - firstArrow: options.axesArrowsBoth ? { size: 7 } : false, + firstArrow: options.yAxis.arrowsBoth ? { size: 7 } : false, lastArrow: { size: 7 }, highlight: false, straightFirst: options.board?.showNavigation ? true : false, diff --git a/lib/Plots/Axes.pm b/lib/Plots/Axes.pm index 9f9f094b5c..5141a255ee 100644 --- a/lib/Plots/Axes.pm +++ b/lib/Plots/Axes.pm @@ -177,6 +177,14 @@ 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 arrows_both + +Configures if arrows should be drawn in both directions (1) or only in the +positive direction (0) at the axis ends. In other words, this is a choice +between the convention that arrows are meant to indicate that the axis lines +continue forever, or the convention that arrows are meant to indicate the +positive direction of the axis only. Default: 0 + =item jsx_options A hash reference of options to be passed to the JSXGraph axis object. @@ -238,14 +246,6 @@ usually not desirable. A better way is to use the "axis background" C to only place the fill on the "axis background" layer, and leave everything else on top of the axis. -=item axes_arrows_both - -Configures if arrows should be drawn in both directions (1) or only in the -positive direction (0) at the axes ends. In other words, this is a choice -between the convention that arrows are meant to indicate that the axes lines -continue forever, or the convention that arrows are meant to indicate the -positive direction of the axes only. Default: 0 - =item mathjax_tick_labels If this is 1, then tick labels will be displayed using MathJax. If this is 0, @@ -291,7 +291,6 @@ sub new { grid_alpha => 40, show_grid => 1, axis_on_top => 0, - axes_arrows_both => 0, mathjax_tick_labels => 1, }, @options @@ -322,7 +321,8 @@ sub axis_defaults { tick_num => 5, major => 1, minor => 3, - minor_grids => 1 + minor_grids => 1, + arrows_both => 0, ); } diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index 2d7045f3a8..5a9e19ff57 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -116,10 +116,7 @@ sub HTML { } } - if ($xvisible || $yvisible) { - $options->{mathJaxTickLabels} = $axes->style('mathjax_tick_labels'); - $options->{axesArrowsBoth} = $axes->style('axes_arrows_both'); - } + $options->{mathJaxTickLabels} = $axes->style('mathjax_tick_labels') if $xvisible || $yvisible; if ($xvisible) { $options->{xAxis}{name} = $axes->xaxis('label'); @@ -128,6 +125,7 @@ sub HTML { $options->{xAxis}{ticks}{labelFormat} = $axes->xaxis('tick_label_format'); $options->{xAxis}{ticks}{labelDigits} = $axes->xaxis('tick_label_digits'); $options->{xAxis}{ticks}{scaleSymbol} = $axes->xaxis('tick_scale_symbol'); + $options->{xAxis}{arrowsBoth} = $axes->xaxis('arrows_both'); $options->{xAxis}{overrideOptions} = $axes->xaxis('jsx_options') if $axes->xaxis('jsx_options'); } if ($yvisible) { @@ -137,6 +135,7 @@ sub HTML { $options->{yAxis}{ticks}{labelFormat} = $axes->yaxis('tick_label_format'); $options->{yAxis}{ticks}{labelDigits} = $axes->yaxis('tick_label_digits'); $options->{yAxis}{ticks}{scaleSymbol} = $axes->yaxis('tick_scale_symbol'); + $options->{yAxis}{arrowsBoth} = $axes->yaxis('arrows_both'); $options->{yAxis}{overrideOptions} = $axes->yaxis('jsx_options') if $axes->yaxis('jsx_options'); } diff --git a/lib/Plots/Tikz.pm b/lib/Plots/Tikz.pm index 591dfe2851..611273f297 100644 --- a/lib/Plots/Tikz.pm +++ b/lib/Plots/Tikz.pm @@ -188,15 +188,16 @@ sub generate_axes { my $xaxis_location = $axes->xaxis('location'); my $xaxis_pos = $xaxis_location eq 'middle' ? $axes->xaxis('position') : 0; my $yaxis_location = $axes->yaxis('location'); - my $yaxis_pos = $yaxis_location eq 'center' ? $axes->yaxis('position') : 0; - my $axis_on_top = $axes->style('axis_on_top') ? "axis on top,\n" : ''; - my $negativeArrow = $axes->style('axes_arrows_both') ? 'Latex[{round,scale=1.6}]' : ''; + my $yaxis_pos = $yaxis_location eq 'center' ? $axes->yaxis('position') : 0; + my $axis_on_top = $axes->style('axis_on_top') ? "axis on top,\n" : ''; + my $xNegativeArrow = $axes->xaxis('arrows_both') ? 'Latex[{round,scale=1.6}]' : ''; + my $yNegativeArrow = $axes->yaxis('arrows_both') ? 'Latex[{round,scale=1.6}]' : ''; my $tikz_options = $axes->style('tikz_options') // ''; my $xlabel = $xvisible ? $axes->xaxis('label') : ''; my $xaxis_style = $xvisible - ? ",\nx axis line style={$negativeArrow-Latex[{round,scale=1.6}]}" + ? ",\nx axis line style={$xNegativeArrow-Latex[{round,scale=1.6}]}" : ",\nx axis line style={draw=none},\nextra y ticks={0}"; my $xtick_style = $xvisible && $axes->xaxis('show_ticks') ? ",\nx tick style={line width=0.6pt}" : ",\nx tick style={draw=none}"; @@ -204,7 +205,7 @@ sub generate_axes { my $ylabel = $yvisible ? $axes->yaxis('label') : ''; my $yaxis_style = $yvisible - ? ",\ny axis line style={$negativeArrow-Latex[{round,scale=1.6}]}" + ? ",\ny axis line style={$yNegativeArrow-Latex[{round,scale=1.6}]}" : ",\ny axis line style={draw=none},\nextra x ticks={0}"; my $ytick_style = $yvisible && $axes->yaxis('show_ticks') ? ",\ny tick style={line width=0.6pt}" : ",\ny tick style={draw=none}"; From c658fb1ffff4f56697b3b713578d8f2090c0ad14 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Sun, 23 Nov 2025 17:22:09 -0600 Subject: [PATCH 10/10] Rework the extra space allocation for axes that are on the edges. This still allocates space initially as it did before, but that is merely to let the JavaScript know where to make adjustments and so that there is space for the axes and tick labels to render. Then the JavaScript adjusts the space after the initial render. This ensures that there is enough space for the labels as well as make the spacing nicer when the board is resized in the imageview dialog. --- htdocs/js/ImageView/imageview.js | 4 +- htdocs/js/Plots/plots.js | 134 +++++++++++++++++++++++-------- lib/Plots/JSXGraph.pm | 9 ++- 3 files changed, 107 insertions(+), 40 deletions(-) diff --git a/htdocs/js/ImageView/imageview.js b/htdocs/js/ImageView/imageview.js index 2d99e2b985..ef59fbab5c 100644 --- a/htdocs/js/ImageView/imageview.js +++ b/htdocs/js/ImageView/imageview.js @@ -219,7 +219,7 @@ if (graphDiv) { graphDiv.style.width = width + 'px'; graphDiv.style.height = height + 'px'; - this.dispatchEvent(new Event('resized.imageview')); + graphDiv.dispatchEvent(new Event('resized.imageview')); } // Re-position the modal. @@ -312,7 +312,7 @@ backdrop.style.opacity = '0.2'; }); modal.addEventListener('hidden.bs.modal', () => { - if (imgType == 'div') this.dispatchEvent(new Event('hidden.imageview')); + if (graphDiv) graphDiv.dispatchEvent(new Event('hidden.imageview')); bsModal.dispose(); modal.remove(); window.removeEventListener('resize', onWinResize); diff --git a/htdocs/js/Plots/plots.js b/htdocs/js/Plots/plots.js index ca1d5532ae..e90199f02f 100644 --- a/htdocs/js/Plots/plots.js +++ b/htdocs/js/Plots/plots.js @@ -182,7 +182,7 @@ const PGplots = { // This axis provides the vertical grid lines. if (options.grid?.x) { - board.create( + plot.xGrid = board.create( 'axis', [ [options.xAxis?.min ?? -5, options.xAxis?.position ?? 0], @@ -246,7 +246,7 @@ const PGplots = { // This axis provides the horizontal grid lines. if (options.grid?.y) { - board.create( + plot.yGrid = board.create( 'axis', [ [options.yAxis?.position ?? 0, options.yAxis?.min ?? -5], @@ -305,7 +305,7 @@ const PGplots = { } if (options.xAxis?.visible) { - const xAxis = board.create( + const xAxis = (plot.xAxis = board.create( 'axis', [ [options.xAxis.min ?? -5, options.xAxis.position ?? 0], @@ -374,16 +374,19 @@ const PGplots = { }, options.xAxis.overrideOptions ?? {} ) - ); + )); xAxis.defaultTicks.generateLabelText = plot.generateLabelText; xAxis.defaultTicks.formatLabelText = plot.formatLabelText; - if (options.xAxis.location !== 'middle') { - board.create( + if (options.xAxis.location !== 'middle' && options.xAxis.name !== '') { + plot.xLabel = board.create( 'text', [ - (xAxis.point1.X() + xAxis.point2.X()) / 2, - options.xAxis.location === 'top' ? board.getBoundingBox()[1] : board.getBoundingBox()[3], + () => (xAxis.point1.X() + xAxis.point2.X()) / 2, + () => + options.xAxis.location === 'top' + ? board.getBoundingBox()[1] - 2 / board.unitY + : board.getBoundingBox()[3] + 2 / board.unitY, options.xAxis.name ?? '\\(x\\)' ], { @@ -392,14 +395,15 @@ const PGplots = { highlight: false, color: 'black', fixed: true, - useMathJax: true + useMathJax: true, + cssStyle: 'line-height: 1;' } ); } } if (options.yAxis?.visible) { - const yAxis = board.create( + const yAxis = (plot.yAxis = board.create( 'axis', [ [options.yAxis.position ?? 0, options.yAxis.min ?? -5], @@ -469,28 +473,33 @@ const PGplots = { }, options.yAxis.overrideOptions ?? {} ) - ); + )); yAxis.defaultTicks.generateLabelText = plot.generateLabelText; yAxis.defaultTicks.formatLabelText = plot.formatLabelText; - if (options.yAxis.location !== 'center') { - board.create( - 'text', + if (options.yAxis.location !== 'center' && options.yAxis.name !== '') { + plot.yLabel = board.create('text', [0, 0, options.yAxis.name ?? '\\(y\\)'], { + anchorX: 'middle', + anchorY: options.yAxis.location === 'right' ? 'bottom' : 'top', + rotate: 90, + highlight: 0, + color: 'black', + fixed: 1, + useMathJax: 1, + cssStyle: 'line-height: 1;' + }); + const transform = board.create( + 'transform', [ - options.yAxis.location === 'right' ? boundingBox[2] : boundingBox[0], - (yAxis.point1.Y() + yAxis.point2.Y()) / 2, - options.yAxis.name ?? '\\(y\\)' + () => + options.yAxis.location === 'right' + ? board.getBoundingBox()[2] - 2 / board.unitX + : board.getBoundingBox()[0] + 2 / board.unitX, + () => (yAxis.point1.Y() + yAxis.point2.Y()) / 2 ], - { - anchorX: 'middle', - anchorY: options.yAxis.location === 'right' ? 'bottom' : 'top', - rotate: 90, - highlight: 0, - color: 'black', - fixed: 1, - useMathJax: 1 - } + { type: 'translate' } ); + transform.bindTo(plot.yLabel); } } @@ -498,6 +507,69 @@ const PGplots = { board.unsuspendUpdate(); + plot.updateBoundingBox = () => { + if (options.board?.showNavigation || (!plot.xAxis && !plot.yAxis)) return; + + const adjustLeft = options.yAxis?.visible && boundingBox[0] < (options.xAxis?.min ?? -5); + const adjustRight = options.yAxis?.visible && boundingBox[2] > (options.xAxis?.max ?? 5); + const adjustBottom = options.xAxis?.visible && boundingBox[3] < (options.yAxis?.min ?? 5); + const adjustTop = options.xAxis?.visible && boundingBox[1] > (options.yAxis?.max ?? -5); + if (!adjustLeft && !adjustRight && !adjustTop && !adjustBottom) return; + + let width = 0; + if (plot.yAxis) { + for (const label of plot.yAxis.defaultTicks.labels) { + const rect = label.rendNode.getBoundingClientRect(); + if (rect.width > width) width = rect.width; + } + if (plot.yLabel) width += plot.yLabel.rendNode.getBoundingClientRect().width + 4; + width += 12; + } + + let height = 0; + if (plot.xAxis) { + for (const label of plot.xAxis.defaultTicks.labels) { + const rect = label.rendNode.getBoundingClientRect(); + if (rect.height > height) height = rect.height; + } + if (plot.xLabel) height += plot.yLabel.rendNode.getBoundingClientRect().height + 4; + height += 8; + } + + const currentBoundingBox = board.getBoundingBox(); + board.setBoundingBox([ + adjustLeft ? options.xAxis.min - width / board.unitX : currentBoundingBox[0], + adjustTop ? options.yAxis.max + height / board.unitY : currentBoundingBox[1], + adjustRight ? options.xAxis.max + width / board.unitX : currentBoundingBox[2], + adjustBottom ? options.yAxis.min - height / board.unitY : currentBoundingBox[3] + ]); + + if (options.yAxis.location !== 'center' && (adjustLeft || adjustRight)) { + const anchorDist = adjustLeft + ? (options.xAxis?.min ?? -5) - board.getBoundingBox()[0] + : board.getBoundingBox()[2] - (options.xAxis?.max ?? -5); + plot.yAxis?.setAttribute({ anchorDist }); + plot.yGrid?.setAttribute({ anchorDist }); + } + if (options.xAxis.location !== 'center' && (adjustBottom || adjustTop)) { + const anchorDist = adjustBottom + ? (options.yAxis?.min ?? -5) - board.getBoundingBox()[3] + : board.getBoundingBox()[1] - (options.yAxis?.max ?? -5); + plot.xAxis?.setAttribute({ anchorDist }); + plot.xGrid?.setAttribute({ anchorDist }); + } + }; + + if (id.startsWith('magnified-')) { + board.containerObj.addEventListener('resized.imageview', () => { + board.resizeContainer(board.containerObj.clientWidth, board.containerObj.clientHeight, true); + setTimeout(plot.updateBoundingBox); + }); + board.containerObj.addEventListener('hidden.imageview', () => JXG.JSXGraph.freeBoard(board)); + } else { + setTimeout(plot.updateBoundingBox); + } + return board; }; @@ -515,19 +587,11 @@ const PGplots = { await drawPromise(boardContainerId); - let jsxBoard = null; container.addEventListener('shown.imageview', async () => { document .getElementById(`magnified-${boardContainerId}`) ?.classList.add(...Array.from(container.classList).filter((c) => c !== 'image-view-elt')); - jsxBoard = await drawPromise(`magnified-${boardContainerId}`); - }); - container.addEventListener('resized.imageview', () => { - jsxBoard?.resizeContainer(jsxBoard.containerObj.clientWidth, jsxBoard.containerObj.clientHeight, true); - }); - container.addEventListener('hidden.imageview', () => { - if (jsxBoard) JXG.JSXGraph.freeBoard(jsxBoard); - jsxBoard = null; + await drawPromise(`magnified-${boardContainerId}`); }); } }; diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index 5a9e19ff57..cc65c8d42a 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -65,7 +65,10 @@ sub HTML { $options->{board}{showNavigation} = $axes->style('jsx_navigation') ? 1 : 0; $options->{board}{overrideOptions} = $axes->style('jsx_options') if $axes->style('jsx_options'); - # Set the bounding box. Add padding for the axes at the edge of graph if needed. + # Set the bounding box. Add padding for the axes at the edge of graph if needed. Note that the padding set here is + # not the final padding used in the end result. The plots.js JavaScript adjusts the padding to fit the axis label + # content. This just needs to add enough padding so that the label content has enough room to render, and so that + # the JavaScript knows where the adjustments are needed. $options->{board}{boundingBox} = [ $xmin - ( $yvisible @@ -80,8 +83,8 @@ sub HTML { ]; $options->{xAxis}{visible} = $xvisible; + ($options->{xAxis}{min}, $options->{xAxis}{max}) = ($xmin, $xmax); if ($xvisible || ($show_grid && $grid->{xmajor})) { - ($options->{xAxis}{min}, $options->{xAxis}{max}) = ($xmin, $xmax); $options->{xAxis}{position} = $xaxis_pos; $options->{xAxis}{location} = $xaxis_loc; $options->{xAxis}{ticks}{scale} = $axes->xaxis('tick_scale'); @@ -90,8 +93,8 @@ sub HTML { } $options->{yAxis}{visible} = $yvisible; + ($options->{yAxis}{min}, $options->{yAxis}{max}) = ($ymin, $ymax); if ($yvisible || ($show_grid && $grid->{ymajor})) { - ($options->{yAxis}{min}, $options->{yAxis}{max}) = ($ymin, $ymax); $options->{yAxis}{position} = $yaxis_pos; $options->{yAxis}{location} = $yaxis_loc; $options->{yAxis}{ticks}{scale} = $axes->yaxis('tick_scale');