Opening the book…
This page covers how to create graphs in WeBWorK PG/PGML problems. It focuses on PGgraphmacros.pl, which produces static GD bitmap images suitable for display-only graphs such as titration curves, enzyme kinetics plots, and dose-response data.
Use this page when: you need to show a graph in a problem but students answer with RadioButtons, PopUp menus, or numeric blanks rather than by clicking on the graph itself. Load PGgraphmacros.pl via the macro block (see Common PG Macros), set up graph variables in the Setup block (see Setup), and always test the rendered output (see Testing and Debugging).
Which graph macro to use
| Need | Macro | Notes |
|---|---|---|
| Display-only graph (titration curve, data plot) | PGgraphmacros.pl |
GD bitmap image, no answer entry |
| Student clicks or draws on graph | parserGraphTool.pl |
JS canvas, registers an answer entry |
Rule of thumb: if students answer via RadioButtons or other PGML widgets (not by clicking the graph), use PGgraphmacros.pl. GraphTool creates a phantom answer entry that can interfere with subsequent PGML answer evaluators.
Minimal example with PGgraphmacros
Copy this block into a new problem file, save, and preview. It draws a single sine curve on a labeled grid.
loadMacros( 'PGstandard.pl', 'MathObjects.pl', 'PGML.pl', 'PGgraphmacros.pl', 'PGcourse.pl', ); # Create graph: init_graph(xmin, ymin, xmax, ymax, ...) $gr = init_graph(-1, 0, 10, 14, axes => [0, 0], grid => [11, 14], size => [480, 400], ); # Plot a function (string formula, not a code reference) add_functions($gr, "2 + 3*sin(x) for x in <0,10> using color:blue and weight:2" ); # Render as an image $graph_img = image(insertGraph($gr), width => 480, height => 400, tex_size => 700); BEGIN_PGML Here is a graph: [$graph_img]* END_PGML
Key points:
init_graphreturns a GD graph object.add_functionstakes a string formula withfor x in <min,max> using color:COLOR and weight:N.image(insertGraph($gr), ...)produces an HTML img tag.- Use
[$graph_img]*in PGML. The asterisk prevents HTML escaping.
init_graph parameters
$gr = init_graph($xmin, $ymin, $xmax, $ymax, axes => [$x_origin, $y_origin], # where axes cross grid => [$x_divisions, $y_divisions], # grid line count size => [$width_px, $height_px], # image dimensions );
axes => [0, 0]places the origin at (0, 0).gridcontrols the number of light grid lines (not tick labels).sizesets the GD image dimensions in pixels.
Adding labels
Labels are positioned in graph coordinates (not pixel coordinates). The constructor is:
new Label($x, $y, $text, $color, $h_align, $v_align)
Horizontal alignment: 'left', 'center', 'right'. Vertical alignment: 'top', 'middle', 'bottom'.
Axis labels at edges of the plot area
$gr->lb(new Label($xmax, $ymin - 0.7, 'x-axis label', 'black', 'right', 'top')); $gr->lb(new Label($xmin - 0.1, $ymax, 'y-axis', 'black', 'right', 'top'));
Tick labels
# Tick labels on y-axis
for $yv (0, 2, 4, 6, 8, 10, 12) {
$gr->lb(new Label($xmin - 0.08, $yv, $yv, 'black', 'right', 'middle'));
}
# Tick labels on x-axis
for $xv (0, 1, 2, 3) {
$gr->lb(new Label($xv, $ymin - 0.3, $xv, 'black', 'center', 'top'));
}
add_functions syntax and computed coefficients
The function string format is:
"FORMULA for x in <XMIN,XMAX> using color:COLOR and weight:WEIGHT"
FORMULA: a Perl math expression usingxas the variable (e.g.,2*x**3 + x - 1).<XMIN,XMAX>: domain interval (angle brackets).color:COLOR:blue,red,green,black, etc.weight:WEIGHT: line thickness in pixels (1, 2, 3, ...).
For computed coefficients, interpolate Perl variables into the string:
$a = 0.5; $b = -1.2; $c = 3.0; $d = 1.5; $func_str = "$a*x**3 + $b*x**2 + $c*x + $d"; add_functions($gr, "$func_str for x in <0,3> using color:blue and weight:2");
Multiple curves
Pass multiple formula strings to add_functions. Each string specifies its own color and weight.
add_functions($gr, "$curve1 for x in <0,10> using color:blue and weight:2", "$curve2 for x in <0,10> using color:red and weight:2", );
Point-by-point curves with moveTo and lineTo
When a formula string cannot express the curve shape (parametric curves, physical models where x is not the independent variable), draw point-by-point with moveTo and lineTo. Use 300 to 400 points for smooth results.
$first_pt = 1;
for $i (0..400) {
$t = $i / 400.0;
# compute ($x_val, $y_val) from $t
if ($first_pt) {
$gr->moveTo($x_val, $y_val);
$first_pt = 0;
} else {
$gr->lineTo($x_val, $y_val, 'blue', 2);
}
}
This approach is essential when the x-axis value is a computed function of the sweep variable rather than the independent variable itself.
Dashed lines
PGgraphmacros has no built-in dash style. Draw dashes manually as short line segments with gaps:
# Horizontal dashed line at y=$pka from x=0 to x=$xeq
for $d (0..29) {
$x1d = $d * 0.10;
$x2d = $x1d + 0.05;
if ($x1d < $xeq) {
if ($x2d > $xeq) { $x2d = $xeq; }
$gr->moveTo($x1d, $pka);
$gr->lineTo($x2d, $pka, 'gray', 1);
}
}
Tune dash length (0.05) and gap (0.05) relative to graph coordinates. For vertical dashes, swap x and y in the loop.
Dots and stamps
Use stamps with closed_circle or open_circle to mark individual points:
# Filled circle at a point $gr->stamps(closed_circle($x, $y, 'black')); # Open circle (hollow) $gr->stamps(open_circle($x, $y, 'black'));
Label sizing: why labels look small
Labels in PGgraphmacros are GD bitmap text. This has important consequences:
tex_sizeonimage()only affects TeX/PDF output, not HTML/GD output.- TeX size commands (
\Large,\huge) in label strings render as literal text in GD, not as formatting. GD::Font->Giantis the largest GD bitmap font (9 by 15 pixels) and is still small.- Rendering at 2x and displaying at 1x makes labels half-size. GD text is fixed-pixel; there is no display-DPI concept.
GD font sizes
Available via $label->font(GD::Font->NAME):
| Font | Pixel size | Notes |
|---|---|---|
Tiny |
5 x 8 | Too small for most uses |
Small |
6 x 12 | Default in most PG builds |
MediumBold |
7 x 13 | Slightly larger |
Large |
8 x 16 | Good default choice |
Giant |
9 x 15 | Largest available |
All are fixed-size bitmap fonts. The size difference between Small and Giant is modest.
Pragmatic fixes for readable labels
Keep size at the final display dimensions (no 2x trick). Improve readability through layout:
# Slightly larger canvas with extra margin for labels
$gr = init_graph(-0.6, -1.2, 3.4, 12.5,
axes => [0, 0],
grid => [8, 5],
size => [520, 420],
);
# Axis labels at edges (right-aligned at plot boundary)
$gr->lb(new Label(3.4, -0.7, 'OH- (equivalents)', 'black', 'right', 'top'));
$gr->lb(new Label(-0.1, 12.5, 'pH', 'black', 'right', 'top'));
# Fewer tick labels to reduce crowding
for $yv (0, 2, 4, 6, 8, 10, 12) {
$gr->lb(new Label(-0.08, $yv, $yv, 'black', 'right', 'middle'));
}
$graph_img = image(insertGraph($gr), width => 520, height => 420, tex_size => 700);
Key techniques:
- Widen graph bounds beyond data range (
-0.6to3.4instead of0to3) to create margin space for labels. - Place axis labels at edges using
$xmax/$ymaxcoordinates with'right', 'top'alignment. - Reduce tick label count to avoid crowding (every 2 or 4 units instead of every 1).
- Use high contrast (black text on white background).
Worked example: triprotic titration curve
A single cubic polynomial cannot produce the correct titration curve shape for a molecule with three pKa values. A cubic has at most one inflection point, but a 3-pKa titration needs three sigmoid steps. The correct approach uses a physical speciation model.
Why not a cubic?
A cubic ax^3 + bx^2 + cx + d through four points:
- Has only one inflection point -- a triprotic titration needs three sigmoid transitions.
- Anchors pKa values at integer equivalents (1, 2) instead of the correct half-equivalents (0.5, 1.5, 2.5).
- Produces a smooth bend, not the characteristic staircase-with-shoulders shape.
Speciation model (correct shape)
Sweep pH and compute the equivalents of base (n_bar) from the charge balance:
n_bar = (Ka1*H^2 + 2*Ka1*Ka2*H + 3*Ka1*Ka2*Ka3)
/ (H^3 + Ka1*H^2 + Ka1*Ka2*H + Ka1*Ka2*Ka3)
This gives three sigmoid steps with buffer flats at half-equivalence points (0.5, 1.5, 2.5 equivalents) and steep transitions at equivalence points (1.0, 2.0 equivalents).
Copy and edit (triprotic titration)
$Ka1_val = 10**(-$pKa1_num);
$Ka2_val = 10**(-$pKaR_num);
$Ka3_val = 10**(-$pKa2_num);
$num_pts = 400;
$ph_lo = 0.5;
$ph_hi = 12.5;
$first_pt = 1;
for $i (0..$num_pts) {
$ph_sweep = $ph_lo + ($ph_hi - $ph_lo) * $i / $num_pts;
$H_conc = 10**(-$ph_sweep);
$denom = $H_conc**3
+ $Ka1_val * $H_conc**2
+ $Ka1_val * $Ka2_val * $H_conc
+ $Ka1_val * $Ka2_val * $Ka3_val;
$nbar = ($Ka1_val * $H_conc**2
+ 2 * $Ka1_val * $Ka2_val * $H_conc
+ 3 * $Ka1_val * $Ka2_val * $Ka3_val) / $denom;
if ($nbar >= 0 && $nbar <= 3.0) {
if ($first_pt) {
$gr->moveTo($nbar, $ph_sweep);
$first_pt = 0;
} else {
$gr->lineTo($nbar, $ph_sweep, 'blue', 2);
}
}
}
This sweeps pH (y-axis) uniformly and computes equivalents (x-axis) as the dependent variable. The moveTo/lineTo approach handles this naturally since x is not the sweep variable.
Alternative: stacked logistic sigmoids
If you do not need physical accuracy, three logistic functions centered at half-equivalents approximate the shape. Note that this version does not pass exactly through pKa at each half-equivalence point; the speciation model is preferred for accuracy.
$k = 10; # steepness
$y0 = 1.0;
$y_top = 11.5;
for $i (0..300) {
$xx = 3.0 * $i / 300.0;
$s1 = 1 / (1 + exp(-$k * ($xx - 0.5)));
$s2 = 1 / (1 + exp(-$k * ($xx - 1.5)));
$s3 = 1 / (1 + exp(-$k * ($xx - 2.5)));
$ph = $y0
+ ($pKa1_num - $y0) * $s1
+ ($pKaR_num - $pKa1_num) * $s2
+ ($pKa2_num - $pKaR_num) * $s3
+ ($y_top - $pKa2_num) * $s3 * 0.20;
# draw with moveTo/lineTo as shown above
}
GraphTool warning: answer evaluator interference
parserGraphTool.pl creates a JS-based interactive canvas where students can draw or place points. Its ans_rule() registers an answer entry (typically AnSwEr0001). This shifts the numbering of subsequent PGML answer blanks and can cause "Unrecognized evaluator type ||" errors on later RadioButtons or other PGML widgets.
Workaround (if you must use GraphTool alongside PGML answers):
- Place GraphTool in a
BEGIN_TEXTblock with\{$gt->ans_rule()\}. - Use
LABELED_ANS($gt_ans_id, $gt_checker)before the PGML block. - Keep RadioButtons and other widgets in a separate
BEGIN_PGMLblock.
Better approach: if the graph is display-only, use PGgraphmacros.pl instead.
Sizing and margins
The size parameter in init_graph sets the GD image pixel dimensions. The width and height in image() control the HTML display size. Always keep these matched at 1x -- rendering at 2x and displaying at 1x shrinks bitmap labels.
# Good: matched sizes, extra margin from wider bounds $gr = init_graph(-0.6, -1.2, 3.4, 12.5, axes => [0, 0], grid => [8, 5], size => [520, 420], ); $graph_img = image(insertGraph($gr), width => 520, height => 420, tex_size => 700);
To create margin space for labels without shrinking the data region, widen the graph bounds beyond the data range (e.g., -0.6 to 3.4 for data spanning 0 to 3).
tex_size controls the image width in TeX/PDF output only (700 = 70% of text width). It has no effect on HTML/GD rendering.
Variable scoping reminder
Do not use my on any variable that PGML needs to interpolate. PG runs in a safe compartment where my variables are invisible to PGML.
# WRONG - invisible to PGML my $graph_img = image(insertGraph($gr), ...); # RIGHT - package-level, visible to PGML $graph_img = image(insertGraph($gr), ...);
Unavailable macros
The following graph-related macros exist in the OPL but are not available in the local PG 2.17 renderer:
PGlateximage.pl(LaTeX-to-image pipeline, requires LaTeX installation)PGtikz.pl(TikZ/pgfplots, requires LaTeX installation)
Stick to PGgraphmacros.pl for display graphs and parserGraphTool.pl for interactive graphs.
Complete working example
Simple function graph with numeric answer -- A full problem that plots a linear function and asks students to read a value from the graph.
## TITLE('Read a value from a graph')
## DESCRIPTION
## A linear function is plotted on a graph. Students read
## the y-value at a given x-coordinate.
## ENDDESCRIPTION
## KEYWORDS('graph','PGgraphmacros','numeric','function plot')
## DBsubject('Biostatistics')
## DBchapter('Data Visualization')
## DBsection('Reading Graphs')
## Date('2026-02-15')
## Author('Author name')
## Institution('Institution name')
DOCUMENT();
loadMacros(
"PGstandard.pl",
"MathObjects.pl",
"PGML.pl",
"PGgraphmacros.pl",
"PGcourse.pl",
);
Context("Numeric");
$m = random(1, 3, 1);
$b = random(1, 4, 1);
$x_ask = random(2, 5, 1);
$y_ans = Real($m * $x_ask + $b);
$gr = init_graph(-1, -1, 8, 20,
axes => [0, 0],
grid => [9, 21],
size => [400, 400],
);
add_functions($gr,
"$m*x + $b for x in <0,8> using color:blue and weight:2"
);
$graph_img = image(insertGraph($gr),
width => 400, height => 400, tex_size => 700);
BEGIN_PGML
The graph below shows a linear function.
[$graph_img]*
What is the y-value when x = [$x_ask]? [________]{$y_ans}
END_PGML
ENDDOCUMENT();
Apply it today
- Use
PGgraphmacros.plfor any display-only graph. ReserveparserGraphTool.plfor problems where students answer by drawing. - Match
init_graph sizetoimage() width/heightat 1x so bitmap labels stay readable. - Widen graph bounds to create margin space for axis labels and tick marks.
- Use
moveTo/lineTofor curves where x is not the independent variable (titration curves, parametric shapes). - Avoid
myon any variable referenced inside PGML blocks.
This page titled 6.3: Making Graphs was last modified on Fri, 27 Feb 2026 15:37:15 GMT and is shared under a CC BY 4.0 license and was authored, remixed, and/or curated by Neil R. Voss.
The book could not be loaded just now.