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. Note that the padding set here is
+ # not the final padding used in the end result. The plots.js JavaScript adjusts the padding to fit the axis label
+ # content. This just needs to add enough padding so that the label content has enough room to render, and so that
+ # the JavaScript knows where the adjustments are needed.
+ $options->{board}{boundingBox} = [
+ $xmin - (
+ $yvisible
+ && ($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;
+ ($options->{xAxis}{min}, $options->{xAxis}{max}) = ($xmin, $xmax);
+ if ($xvisible || ($show_grid && $grid->{xmajor})) {
+ $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;
+ ($options->{yAxis}{min}, $options->{yAxis}{max}) = ($ymin, $ymax);
+ if ($yvisible || ($show_grid && $grid->{ymajor})) {
+ $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');
+ }
+ }
+
+ $options->{mathJaxTickLabels} = $axes->style('mathjax_tick_labels') if $xvisible || $yvisible;
+
+ 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}{arrowsBoth} = $axes->xaxis('arrows_both');
+ $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}{arrowsBoth} = $axes->yaxis('arrows_both');
+ $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 +164,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 +185,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 +306,97 @@ 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_min_y = $data->str_to_real($data->style('fill_min_y'));
+ my $fill_max_y = $data->str_to_real($data->style('fill_max_y'));
+ my $fill_layer = $self->get_layer($data, 1) // $self->get_layer($data);
+ my $fillOptions = Mojo::JSON::encode_json({
+ strokeWidth => 0,
+ 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" . "})";
+ }
+ if ($fill_min_y ne '' && $fill_max_y ne '') {
+ $self->{JS} .=
+ ".filter(p => {"
+ . "return p.usrCoords[2] >= $fill_min_y && p.usrCoords[2] <= $fill_max_y ? true : false"
+ . "})";
+ }
+ $self->{JS} .= ";const points2 = curve_${fill}.points";
+ if ($fill_min ne '' && $fill_max ne '') {
+ $self->{JS} .=
+ ".filter(p => {"
+ . "return p.usrCoords[1] >= $fill_min && p.usrCoords[1] <= $fill_max ? true : false" . "})";
+ }
+ if ($fill_min_y ne '' && $fill_max_y ne '') {
+ $self->{JS} .=
+ ".filter(p => {"
+ . "return p.usrCoords[2] >= $fill_min_y && p.usrCoords[2] <= $fill_max_y ? true : false"
+ . "})";
+ }
+ $self->{JS} .=
+ ";this.dataX = points1.map( p => p.usrCoords[1] ).concat("
+ . "points2.map( p => p.usrCoords[1] ).reverse());"
+ . "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 +404,78 @@ 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;}";
+
+ if (ref $path eq 'ARRAY') {
+ ($start_x, $start_y) = (', ' . $path->[0], ', ' . $path->[1]) if $cycle && $_ == 0;
+ $self->{JS} .= "board.create('curve', [[$path->[0]], [$path->[1]]], { visible: false }),\n";
+ next;
+ }
+
+ ($start_x, $start_y) =
+ (', ' . $path->{Fx}->eval($var => $path->{tmin}), ', ' . $path->{Fy}->eval($var => $path->{tmin}))
+ if $cycle && $_ == 0;
+
+ 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 +518,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,170 +537,77 @@ 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);";
- return;
-}
+ # JSXGraph arcs cannot make a 360 degree revolution. So in the case that the start and end point are the same,
+ # move the end point back around the circle a tiny amount.
+ if ($x2 == $x3 && $y2 == $y3) {
+ my $theta = atan2($y2 - $y1, $x2 - $x1) + 2 * 3.14159265358979 - 0.0001;
+ $x3 = $x1 + cos($theta);
+ $y3 = $y1 + sin($theta);
+ }
-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
+ $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 draw {
@@ -508,49 +615,25 @@ sub draw {
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 +644,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,24 +652,37 @@ 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 $anchor = $label->style('anchor');
+ my $rotate = $label->style('rotate');
+ my $padding = $label->style('padding') || 4;
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},
strokeColor => $self->get_color($label->style('color')),
- anchorX => $h_align eq 'center' ? 'middle' : $h_align,
- anchorY => $v_align,
- cssStyle => 'padding: 3px;',
- useMathJax => 1,
+ $anchor ne ''
+ ? (angleAnchor => $anchor, anchorX => 'middle', anchorY => 'middle')
+ : (anchorX => $h_align eq 'center' ? 'middle' : $h_align, anchorY => $v_align),
+ $rotate ? (rotate => $rotate) : (),
+ cssStyle => "line-height: 1; padding: ${padding}px;",
+ useMathJax => 1,
});
$textOptions = "JXG.merge($textOptions, " . Mojo::JSON::encode_json($label->style('jsx_options')) . ')'
if $label->style('jsx_options');
- $self->{JS} .= "board.create('text', [$x, $y, '$str'], $textOptions);";
+ $self->{JS} .= "plot.createLabel($x, $y, '$str', $textOptions);";
}
# JSXGraph only produces HTML graphs and uses TikZ for hadrcopy.
diff --git a/lib/Plots/Plot.pm b/lib/Plots/Plot.pm
index 660edba168..b03262739f 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,23 @@ 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])
- } } @$paths
+ map {
+ @$_ == 2
+ ? [@$_]
+ : {
+ Fx => $data->get_math_object($_->[0], $var),
+ Fy => $data->get_math_object($_->[1], $var),
+ tmin => $data->str_to_real($_->[2]),
+ tmax => $data->str_to_real($_->[3]),
+ @$_[ 4 .. $#$_ ]
+ }
+ } @$paths
];
$data->{function} = { var => $var, steps => $steps };
- $data->style(color => 'default_color', width => 2, %options);
+ $data->style(color => 'default_color', width => 2, mark_size => 2, %options);
$self->add_data($data);
return $data;
@@ -329,8 +327,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 +350,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 +374,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 +397,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 +419,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 +442,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 +461,7 @@ sub _add_stamp {
$data->add($x, $y);
$data->style(
color => 'default_color',
- size => 4,
+ radius => 4,
symbol => 'circle',
@options
);
@@ -485,8 +484,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..611273f297 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,465 @@ 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 $xNegativeArrow = $axes->xaxis('arrows_both') ? 'Latex[{round,scale=1.6}]' : '';
+ my $yNegativeArrow = $axes->yaxis('arrows_both') ? 'Latex[{round,scale=1.6}]' : '';
+ my $tikz_options = $axes->style('tikz_options') // '';
+
+ my $xlabel = $xvisible ? $axes->xaxis('label') : '';
+ my $xaxis_style =
+ $xvisible
+ ? ",\nx axis line style={$xNegativeArrow-Latex[{round,scale=1.6}]}"
+ : ",\nx axis line style={draw=none},\nextra y ticks={0}";
+ my $xtick_style =
+ $xvisible && $axes->xaxis('show_ticks') ? ",\nx tick style={line width=0.6pt}" : ",\nx tick style={draw=none}";
+
+ my $ylabel = $yvisible ? $axes->yaxis('label') : '';
+ my $yaxis_style =
+ $yvisible
+ ? ",\ny axis line style={$yNegativeArrow-Latex[{round,scale=1.6}]}"
+ : ",\ny axis line style={draw=none},\nextra x ticks={0}";
+ my $ytick_style =
+ $yvisible && $axes->yaxis('show_ticks') ? ",\ny tick style={line width=0.6pt}" : ",\ny tick style={draw=none}";
+
+ 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') {
@@ -280,21 +568,32 @@ sub draw {
my $r = sqrt(($x2 - $x1)**2 + ($y2 - $y1)**2);
my $theta1 = 180 * atan2($y2 - $y1, $x2 - $x1) / 3.14159265358979;
my $theta2 = 180 * atan2($y3 - $y1, $x3 - $x1) / 3.14159265358979;
- $theta1 += 360 if $theta1 < 0;
- $theta2 += 360 if $theta2 < 0;
- $tikzCode .= "\\draw[$tikz_options] (axis cs:$x2,$y2) arc ($theta1:$theta2:$r);\n";
+ $theta2 += 360 if $theta2 <= $theta1;
+ $tikzCode .= $self->draw_on_layer(
+ "\\draw[name path=$curve_name, $draw_options->[0]] (axis cs:$x2,$y2) "
+ . "arc[start angle=$theta1, end angle=$theta2, radius = $r];\n",
+ $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,136 @@ 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)");
+
+ ++$count while $self->{names}{"${curve_name}_$count"};
+ push(@pathData, ["${curve_name}_$count"]);
+ $self->{names}{ $pathData[-1][0] } = 1;
+
+ if (ref $path eq 'ARRAY') {
+ $tikzCode .=
+ "\\addplot[name path=$pathData[-1][0], draw=none] coordinates {($path->[0], $path->[1])};\n";
+ push(@{ $pathData[-1] }, @$path, @$path);
+ next;
+ }
+
+ push(
+ @{ $pathData[-1] },
+ $path->{Fx}->eval($var => $path->{tmin}),
+ $path->{Fy}->eval($var => $path->{tmin}),
+ $path->{Fx}->eval($var => $path->{tmax}),
+ $path->{Fy}->eval($var => $path->{tmax})
+ );
+
+ my $xfunction = $data->function_string($path->{Fx}, 'PGF', $var);
+ my $yfunction = $data->function_string($path->{Fy}, 'PGF', $var);
+
+ my $steps = $path->{steps} // $data->{function}{steps};
+
+ $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_min_y = $data->style('fill_min_y');
+ my $fill_max_y = $data->style('fill_max_y');
+ my $fill_reverse = $data->style('fill_reverse');
+ my $fill_range =
+ $fill_min ne '' && $fill_max ne '' && $fill_min_y ne '' && $fill_max_y ne ''
+ ? ", soft clip={($fill_min, $fill_min_y) rectangle ($fill_max, $fill_max_y)}"
+ : $fill_min ne '' && $fill_max ne '' ? ", soft clip={domain=$fill_min:$fill_max}"
+ : $fill_min_y ne '' && $fill_max_y ne '' ? ", soft clip={domain y=$fill_min_y:$fill_max_y}"
+ : '';
+ my $fill_layer = $data->style('fill_layer') || $layer;
+ my $reverse = $fill_reverse eq '' ? '' : $fill_reverse ? ', reverse' : 'reverse=false';
+ $tikzCode .=
+ "\\begin{scope}[/tikz/fill between/on layer=$fill_layer]\\begin{pgfonlayer}{$fill_layer}"
+ . "\\clip\\axisclippath;\n"
+ if $fill_layer;
+ $tikzCode .=
+ "\\addplot[$fill_color, fill opacity=$opacity] "
+ . "fill between[of=$name and $fill$fill_range$reverse];\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 +739,38 @@ 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 = $label->style('anchor');
+ $anchor = join(' ',
+ $v_align eq 'top' ? 'north' : $v_align eq 'bottom' ? 'south' : (),
+ $h_align eq 'left' ? 'west' : $h_align eq 'right' ? 'east' : ())
+ if $anchor eq '';
+ my $padding = $label->style('padding') || 4;
$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;
+ $tikz_options = "inner sep=${padding}pt, $tikz_options";
$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/lib/SampleProblemParser.pm b/lib/SampleProblemParser.pm
deleted file mode 100644
index 30b5f8398b..0000000000
--- a/lib/SampleProblemParser.pm
+++ /dev/null
@@ -1,253 +0,0 @@
-package SampleProblemParser;
-use parent qw(Exporter);
-
-use strict;
-use warnings;
-use experimental 'signatures';
-use feature 'say';
-
-use File::Basename qw(dirname basename);
-use File::Find qw(find);
-use Pandoc;
-
-our @EXPORT_OK = qw(parseSampleProblem generateMetadata getSampleProblemCode);
-
-=head1 NAME
-
-SampleProblemParser - Parse the documentation in a sample problem in the /doc
-directory.
-
-=head2 C
-
-Parse a PG file with extra documentation comments. The input is the file and a
-hash of global variables:
-
-=over
-
-=item C: A reference to a hash which has information (name, directory,
-types, subjects, categories) of every sample problem file.
-
-=item C: A reference to a hash of macros to include as links
-within a problem.
-
-=item C: The root directory of the POD.
-
-=item C: The url of the pg_doc home.
-
-=item C: The html url extension (including the dot) to use for pg
-doc links. The default is the empty string.
-
-=back
-
-=cut
-
-sub parseSampleProblem ($file, %global) {
- my $filename = basename($file);
- open(my $FH, '<:encoding(UTF-8)', $file) or do {
- warn qq{Could not open file "$file": $!};
- return {};
- };
- my @file_contents = <$FH>;
- close $FH;
-
- my (@blocks, @doc_rows, @code_rows, @description);
- my (%options, $descr, $type, $name);
-
- $global{url_extension} //= '';
-
- while (my $row = shift @file_contents) {
- chomp($row);
- $row =~ s/\t/ /g;
- if ($row =~ /^#:%\s*(categor(y|ies)|types?|subjects?|see_also|name)\s*=\s*(.*)\s*$/) {
- # skip this, already parsed.
- } elsif ($row =~ /^#:%\s*(.*)?/) {
- # The row has the form #:% section = NAME.
- # This should parse the previous named section and then reset @doc_rows and @code_rows.
- push(
- @blocks,
- {
- %options,
- doc => pandoc->convert(markdown => 'html', join("\n", @doc_rows)),
- code => join("\n", @code_rows)
- }
- ) if %options;
- %options = split(/\s*:\s*|\s*,\s*|\s*=\s*|\s+/, $1);
- @doc_rows = ();
- @code_rows = ();
- } elsif ($row =~ /^#:/) {
- # This section is documentation to be parsed.
- $row = $row =~ s/^#:\s?//r;
-
- # Parse any LINK/PODLINK/PROBLINK commands in the documentation.
- if ($row =~ /(POD|PROB)?LINK\('(.*?)'\s*(,\s*'(.*)')?\)/) {
- my $link_text = defined($1) ? $1 eq 'POD' ? $2 : $global{metadata}{$2}{name} : $2;
- my $url =
- defined($1)
- ? $1 eq 'POD'
- ? "$global{pod_root}/" . $global{macro_locations}{ $4 // $2 }
- : "$global{pg_doc_home}/$global{metadata}{$2}{dir}/" . ($2 =~ s/.pg$/$global{url_extension}/r)
- : $4;
- $row = $row =~ s/(POD|PROB)?LINK\('(.*?)'\s*(,\s*'(.*)')?\)/[$link_text]($url)/gr;
- }
-
- push(@doc_rows, $row);
- } elsif ($row =~ /^##\s*(END)?DESCRIPTION\s*$/) {
- $descr = $1 ? 0 : 1;
- } elsif ($row =~ /^##/ && $descr) {
- push(@description, $row =~ s/^##\s*//r);
- push(@code_rows, $row);
- } else {
- push(@code_rows, $row);
- }
- }
-
- # The last @doc_rows must be parsed then added to the @blocks.
- push(
- @blocks,
- {
- %options,
- doc => pandoc->convert(markdown => 'html', join("\n", @doc_rows)),
- code => join("\n", @code_rows)
- }
- );
-
- return {
- name => $global{metadata}{$filename}{name},
- blocks => \@blocks,
- code => join("\n", map { $_->{code} } @blocks),
- description => join("\n", @description)
- };
-}
-
-=head2 C
-
-Build a hash of metadata for all PG files in the given directory. A reference
-to the hash that is built is returned.
-
-=cut
-
-sub generateMetadata ($problem_dir, %options) {
- my $index_table = {};
-
- find(
- {
- wanted => sub {
- say "Reading file: $File::Find::name" if $options{verbose};
-
- if ($File::Find::name =~ /\.pg$/) {
- my $metadata = parseMetadata($File::Find::name, $problem_dir);
- unless (@{ $metadata->{types} }) {
- warn "The type of sample problem is missing for $File::Find::name.";
- return;
- }
- unless ($metadata->{name}) {
- warn "The name attribute is missing for $File::Find::name.";
- return;
- }
- $index_table->{ basename($File::Find::name) } = $metadata;
- }
- }
- },
- $problem_dir
- );
-
- return $index_table;
-}
-
-my @macros_to_skip = qw(
- PGML.pl
- PGcourse.pl
- PGstandard.pl
-);
-
-sub parseMetadata ($path, $problem_dir) {
- open(my $FH, '<:encoding(UTF-8)', $path) or do {
- warn qq{Could not open file "$path": $!};
- return {};
- };
- my @file_contents = <$FH>;
- close $FH;
-
- my @problem_types = qw(sample technique snippet);
-
- my $metadata = { dir => (dirname($path) =~ s/$problem_dir\/?//r) =~ s/\/*$//r };
-
- while (my $row = shift @file_contents) {
- if ($row =~ /^#:%\s*(categor(y|ies)|types?|subjects?|see_also|name)\s*=\s*(.*)\s*$/) {
- # The row has the form #:% categories = [cat1, cat2, ...].
- my $label = lc($1);
- my @opts = $3 =~ /\[(.*)\]/ ? map { $_ =~ s/^\s*|\s*$//r } split(/,/, $1) : ($3);
- if ($label =~ /types?/) {
- for my $opt (@opts) {
- warn "The type of problem must be one of @problem_types"
- unless grep { lc($opt) eq $_ } @problem_types;
- }
- $metadata->{types} = [ map { lc($_) } @opts ];
- } elsif ($label =~ /^categor/) {
- $metadata->{categories} = \@opts;
- } elsif ($label =~ /^subject/) {
- $metadata->{subjects} = [ map { lc($_) } @opts ];
- } elsif ($label eq 'name') {
- $metadata->{name} = $opts[0];
- } elsif ($label eq 'see_also') {
- $metadata->{related} = \@opts;
- }
- } elsif ($row =~ /loadMacros\(/) {
- chomp($row);
- # Parse the macros, which may be on multiple rows.
- my $macros = $row;
- while ($row && $row !~ /\);\s*$/) {
- $row = shift @file_contents;
- chomp($row);
- $macros .= $row;
- }
- # Split by commas and pull out the quotes.
- my @macros = map {s/['"\s]//gr} split(/\s*,\s*/, $macros =~ s/loadMacros\((.*)\)\;$/$1/r);
- $metadata->{macros} = [];
- for my $macro (@macros) {
- push(@{ $metadata->{macros} }, $macro) unless grep { $_ eq $macro } @macros_to_skip;
- }
- }
- }
-
- return $metadata;
-}
-
-=head2 C
-
-Parse a PG file with extra documentation comments and strip that all out
-returning the clean problem code. This returns the same code that the
-C returns, except at much less expense as it does not parse
-the documentation, it does not require that the metadata be parsed first, and it
-does not need macro POD information.
-
-=cut
-
-sub getSampleProblemCode ($file) {
- my $filename = basename($file);
- open(my $FH, '<:encoding(UTF-8)', $file) or do {
- warn qq{Could not open file "$file": $!};
- return '';
- };
- my @file_contents = <$FH>;
- close $FH;
-
- my (@code_rows, $inCode);
-
- while (my $row = shift @file_contents) {
- chomp($row);
- $row =~ s/\t/ /g;
- if ($row =~ /^#:(.*)?/) {
- # This is documentation so skip it.
- } elsif ($row =~ /^\s*(END)?DOCUMENT.*$/) {
- $inCode = $1 ? 0 : 1;
- push(@code_rows, $row);
- } elsif ($inCode) {
- push(@code_rows, $row);
- }
- }
-
- return join("\n", @code_rows);
-}
-
-1;
diff --git a/lib/Value/AnswerChecker.pm b/lib/Value/AnswerChecker.pm
index fb4dacb06c..3ed4f18535 100644
--- a/lib/Value/AnswerChecker.pm
+++ b/lib/Value/AnswerChecker.pm
@@ -555,57 +555,57 @@ sub format_matrix_HTML {
my ($rows, $cols) = (scalar(@{$array}), scalar(@{ $array->[0] }));
my $HTML = "";
my $class = 'class="ans_array_cell"';
- my $cell = "display:table-cell;vertical-align:middle;";
+ my $cell = "display:table-cell;vertical-align:middle;text-align:center;";
my $pad = "padding:4px 0;";
- if ($sep) { $sep = '' . $sep . '' }
- else { $sep = '' }
- $sep = '' . $sep . '';
+ if ($sep) { $sep = '' . $sep . '
' }
+ else { $sep = '' }
+ $sep = ' ' . $sep . '