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/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/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 5b75c34640..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')); - return int(($ymax - $y) * $plots->{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 = $plots->{width}; - my $height = $plots->{height}; - - # 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 f4d386360f..65f8c01b27 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,41 +388,72 @@ 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 $jsFunctionx = 'function (x){'; - my $jsFunctiony = 'function (x){'; + my @paths = @{ $data->{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 $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);"; + $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; } 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 +496,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,220 +515,95 @@ 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], $circleOptions);" if $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 }, 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; } -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'), - description => $axes->style('aria_description'), - 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); - 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'); @@ -561,7 +614,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 @@ -569,14 +622,23 @@ 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, diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm index 660edba168..85bbab0d8e 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,22 +294,19 @@ 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 = (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 }; - $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 37cbcd6b78..6da6314397 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,spath3'); $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,464 @@ 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 = ''; - } - 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 $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'); } - 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" : ''; - - $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 $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"); + } elsif (!@drawOptions) { + push(@drawOptions, 'draw=none'); } - $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 $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'; + 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( + "\\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') { @@ -282,19 +569,31 @@ 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( + "\\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) { 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}"; + 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=$min:$max, samples=$f->{xsteps}"; $plot = "{$function}"; } } else { @@ -302,100 +601,121 @@ 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})"; } } - } - 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"; } - $tikz_options .= ", 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; - 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"; + + # '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; 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}) { + # 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 $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 +724,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/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; diff --git a/macros/graph/plots.pl b/macros/graph/plots.pl index 64ad60e51a..07f449a691 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], ... ); @@ -162,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( [ @@ -194,9 +237,34 @@ =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 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,10 +277,12 @@ =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( @@ -262,8 +332,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 +374,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 +396,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 +411,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 +425,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 +480,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 +541,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 +566,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 +598,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 +640,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 +675,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} >>,