by Paul Murrell http://orcid.org/0000-0002-3224-8858
This document
is licensed under a Creative
Commons Attribution 4.0 International License.
This document describes the 'vwline' package, which provides an R interface for drawing variable-width curves. The package provides functions to draw line segments through a set of locations, or a smooth curve relative to a set of control points, with the width of the line allowed to vary along the length of the line.
This document describes the 'vwline' package for drawing variable-width curves in R. For example, of the three lines shown below, standard R graphics can draw lines A and B (a line between two points, with a constant width) but not line C (a line between two points with a variable width).
It is possible in R graphics to draw line C as
a filled polygon, by explicitly specifying the outline of the
line boundary. For example, in this case the boundary is just a triangle.
Several R packages provide functions for drawing polygons of this
sort, where the boundary is completely specified from data, or
the boundary can be easily calculated from data as a simple
x-shift or y-shift. Examples include
geom_ribbon
(and geom_smooth
) in 'ggplot2',
kiteChart
in 'plotrix', and
the variations on "violin plots"
in the 'beanplot' package.
However, in situations where the line itself is not simple and the width is not just an x-shift or y-shift calculation, the description of the boundary can be much less straightforward. In the example below, the line is an X-spline, which R can draw as a line with constant width (lines D and E), but when the width of the line is allowed to vary, standard R graphics cannot help, and it is not straightforward to represent the line as a polygon because the boundary becomes a non-trivial path (line F).
The 'vwline' package provides a set of functions that make it easy to specify a variable-width line, like line F above.
The ability to describe variable-width lines has some direct applications to producing plots. For example, there are variable-width lines in Minard's famous map and VanderPlas and Hofmann have discussed the importance of controlling line widths with regard to the sine illusion. However, the work described in this document is more focused on adding a basic drawing primitive to R graphics. The general motivation is to provide these facilities in R so that users can avoid having to manually "touch up" plots in other software. For example, in the plot below (provided by Thomas Lumley, using data from the Auckland Transport real-time API), the plot was drawn with R, but the blue and orange arrows at the top-right of the plot were added using a drawing program outside of R.
It is better if all actions that make up a final plot can be captured in code and therefore recorded, automated, and shared. In this particular context, the variable-width line facility is meant to obviate the need for something like Adobe Illustrator's variable-width stroke tool, or SynFig's Advanced Outline Layer. This work is also influenced by the SVG proposal for variable-width lines (also see the discussion by Doug Schepers) and the Inkscape power stroke proposal.
The first approach to drawing a variable-width line with 'vwline'
is provided by the grid.vwcurve
function. This function
accepts a set of x/y locations and a set of widths, all of which
recycle as necessary. The idea with this function is that we know
the placement of the line and the
width of the line at all points on a curve.
The function generates (and draws) a polygon using the following algorithm:
This function can be used to produce the sort of result that 'beanplot' and other packages already provide, but with the additional flexibility that the line does not have to be straight and the widths do not have to be aligned with the x-axis or with the y-axis. In the images below, the main variable-width line is thick and black; a thin white line is drawn to represent the x/y locations of the centre of the line.
The second approach to drawing variable-width lines, which is provided
by the grid.vwXspline
function, also accepts
x/y locations, plus a set of widths, but this time the
locations describe control points for a curve, rather than
explicit locations for the line to pass through.
More specifically, with this approach the x/y locations describe the control points for an X-spline, and the widths describe an amount to offset additional control points to the left and right of the main control points. The algorithm used to generate a polygon is as follows:
This approach is similar to work on simulating brush strokes by Pham and, to a lesser extent, Chua. Klassen also discusses this sort of approach for representing fonts.
This function can be useful if we want to produce a variable-width curve, but do not want to specify every x/y location and width for the curve. In other words, we want to draw a smooth curve with smoothly-varying width, without having to specify the curve or the width in minute detail.
The third approach to drawing variable-width lines is
provided by the vwline
function. This is similar
to the vwcurve
function, because every location
and width for the line is specified explicitly, but it is
designed for a situation where the main line really is a series
of straight line segments (e.g., Minard's map).
The difference is that this function treats each location
as a corner (where vwcurve
pretends that the line is
a smooth curve with no sharp corners).
polysimplify
from the 'polyclip' package).
This function produces a non-smooth line, which may be useful if we wish to emphasise that the width is only known at specific locations along the line.
The fourth approach to drawing variable-width lines, which is
provided by the brushXspline
function, takes
a set of x/y control points to describe the line (an X-spline), a
"brush" (a geometric shape)
that is used to "sweep" the line, plus a set of widths
that are used to scale the brush as it moves along the line.
This approach is not entirely unlike Knuth's MetaFont, though with far less control over the path that is to be swept.
The brushXspline
function can be useful if we want to produce a variable-width
curve and do not want to specify every x/y location or width
for the curve (so cannot use vwcurve
or
vwline
),
but we want the perpendicular width of the curve
to be more accurately represented (so cannot use vwXspline
).
The examples below construct a vwXspline
and a
brushXspline
through the same set of control points.
In the brushXspline
case, we can see the effect
of the width being perpendicular to the line at all points along the line.
The final approach, provided by the offsetXspline
function,
generates a mathematical description of
"offset curves" and draws those offset curves.
The problem of determining the outline for a curve, when the width is fixed, is known as generating an "offset curve" or an "offset polygon". This arises in a variety of contexts, including stroking a path with a non-zero line width, and producing buffer regions for spatial objects. With offset curves, we start with a mathematical definition of the curve (e.g., a Bezier curve), and we derive a mathematical expression for curves that are offset by a fixed amount, then we render the offset curve.
Examples of mathematical solutions for offset curves are often focused on Bezier curves, e.g., Hoschek and Hain et al. A review was given by Elber et al.
A general approach for offset polygons was described by Chen and McMains. This algorithm has been implemented in the Clipper library, which has an R interface via the polyclip package. The CGAL library can also generate polygon offsets, particularly the 2D Straight Skeleton and Polygon Offsetting package.
This function implements the general formulation for a variable offset curve that is described in Chen and Lin. The difficult part of this approach is determining a function for the offset curve (which requires a function for the unit normal to the main curve and a function for the width of the curve). However, once these equations have been found, the algorithm is straightforward.
polysimplify
from the 'polyclip' package).
The result from this function should be similar to the
result from brushXspline
(as shown below).
The offsetXspline
function provides a more robust (less
heuristic) approach, but brushXspline
offers more
flexibility via the possibility of different brushes
(see Specifying brushes).
Having introduced the basic idea behind the main functions in the 'vwline' package, we will now explore how these functions work in a little more detail.
For the functions vwcurve
, vwline
,
and vwXspline
,
we specify widths explicitly,
either as a width for
every x/y location on the line or as
a width for every x/y control point.
For vwcurve
and vwXspline
,
widths are just a numeric vector or a 'grid' unit and
the width is the same either side of the main curve.
The vwline
function is slightly more flexible and
accepts a numeric vector or
'grid' unit for the width, but also accepts a list of separate
left and right widths, as generated by the widthSpec
function. This allows different widths to the left and right
of the main curve.
The vwline
function also has a stepWidth
argument, which means that widths are not linearly interpolated along
the main curve, but remain constant along each segment (and the final
width is silently ignored).
For the brushXspline
and offsetXspline
functions,
the line width
is not as tightly coupled with the x/y locations.
For these functions, the x/y locations describe a curve
and the width argument separately describes how the line width
should vary along the length of the curve. The simplest approach
is to specify a numeric vector of width values, in which case the
first value is used as the width at the start of the curve, the last
value is used as the width at the end of the curve, and the other
widths are spaced evenly along the curve. By default, the
widths are interpreted as numbers of millimetres.
By default,
the change in width along the line is controlled by an X-spline
with a shape parameter of -1. The
brushXspline
example from the previous section is
repeated below (the left image).
Notice that there are four x/y locations for
control points, but only three width values. This means that
the width of the line starts at 0, ends at 0, and is 0.2 in the
middle of the curve, with width varying smoothly according to an X-spline
through (0, 0), (0.5, 0.2), (1, 0). The image below on the right
shows what this width X-spline looks like.
The spline that controls the width for brushXspline
and offsetXspline
can also be
specified explicitly with the widthSpline
function.
This allows the set of widths to be defined along with a set of distances
along the line (the x-values for the width X-spline).
The distances can just
be numeric values, in which case they are interpreted as proportions
of the line length, or they can be 'grid' units (e.g., millimetres).
There is also a shape
argument to control the shape of the
width X-spline (though note that positive shape values
will mean that the maximum explicit width may not be achieved; see
the appendix on X-splines). Finally, there
is a rep
argument to control whether the widths
repeat or stay fixed for distances along the curve that are outside
the explicit distances specified by the width spline.
In the example below, we specify that the line width should start with a width of zero at one quarter of the distance along the line, rise smoothly to 1cm half way along the line, then drop again to zero at the three-quarter point, and the pattern should repeat towards either end of the line.
Several variable-width-line functions provide an angle
argument to control the angle at which the width is calculated or
at which the brush is placed. By default, this argument has the
special value "perp"
, but a numeric value can be
specified instead. For example, the brush in
brushXspline
can be fixed upright (a rotation of 0)
as it sweeps the curve
(see the left image below),
and the vwXspline
function can calculate left and right
control points at a fixed angle (here 45 degrees)
from the main control points
(see the right image below). This can produce something like
a calligraphic effect.
This angle
argument is not implemented for
vwline
(because the result will be the same as for
vwcurve
) or for
offsetXspline
(because the offset curve is based on
unit normals, which are, by definition perpendicular to the main curve).
Almost all functions allow control over the line end style,
via a lineend
argument.
This is similar to line end styling on normal (fixed-width) lines,
so it is possible to specify "butt"
, "square"
,
or "round"
line ends (the default is "butt"
).
However, because the edges of a variable-width
line are almost never parallel to the
main curve, there is an additional "mitre"
line end style.
For the same reason, a "round"
line ending does not
usually produce a semi-circle and a "square"
line ending
has two corners, but they are not usually square.
As demonstrated below, if the width is diverging at the end of the
line, a mitre end will automatically convert to a square end.
This conversion will also occur when a mitre end would be too long;
a mitrelimit
argument allows control over when
this occurs.
When the width is decreasing rapidly, it is also possible for
a square end to be automatically converted to a mitre.
Line end styles are not implemented for brushXspline
because the line end shape is affected by the brush shape instead.
The vwline
function (and only that function) also provides a
linejoin
argument for controlling the join style
on corners. This takes the value "round"
(the default),
"mitre"
, or "bevel"
.
The main difference compared to normal (fixed-width) lines is
that a round corner is not usually just an arc of a circle
(because the line edge typically approaches the corner at a different angle
from either side of the corner, as in the examples below).
Line joins are not implemented for
vwcurve()
because edges already meet at a single point for
every corner. They are not implemented for vwXspline()
for similar reasons.
The brushXspline
and offsetXspline
functions will accept a shape of 0, which produces a main curve
with sharp corners. The current behaviour for offsetXspline
in this case is effectively to produce a bevel join. The
behaviour for brushXspline
will depend on the brush
used, but is likely to produce a non-smooth (jaggy) corner.
For the brushXspline
function, the
variable-width line is calculated
as the region swept out by
a "brush" shape. By default, this brush is a (very thin) vertical line
(that is rotated perpendicular to the line), but we can specify
alternative brush shapes.
Another predefined brush shape is provided by the circleBrush
function, but a
brush is just a list of x/y locations within the range -1 to 1, so
we can define any shape we want. The examples below demonstrate the
use of circleBrush
, which is most noticeable at the
line ends, and a custom brush that only sweeps the left (top)
half of the line.
All variable-width line functions also provide an open
argument that can be set to FALSE
to make the
line "closed" (the last point is connected back to the first point).
In the example below, we place 10 control points in a circle (anticlockwise) and make a closed line (so the resulting curve is very close to a circle). We then sweep the curve with a half brush that varies smoothly between 2mm and 8mm (so the inside of the curve is swept at a width of 1mm to 4mm). The right-hand image below shows the shape of the width spline used in this example.
The width specifications for different functions will produce
different results for closed curves.
For vwcurve
, vwline
, and vwXspline
,
we provide a width at each location, so the first
width also becomes the last width (it automatically cycles).
However, for brushXspline
and offsetXspline
,
we provide widths from the start to the end of the line,
so if we want a smooth
transition where the head of the line meets the tail of the line,
we need to make sure that the width at the start matches the width
at the end.
By default, brushXspline
sweeps the brush
continuously along the line, but the spacing
argument also allows the brush
to be placed at only discrete locations along the line.
The spacing
argument is recycled to cover the
whole line.
In the example below, we draw a vertical brush very 1mm along the line (left image) and a circle brush every .05 of the way along the line (right image), with the brush size varying along the line in both cases.
A "line" drawn by the functions in 'vwline' is actually a filled
polygon outline. By default, this polygon is drawn with no
outline and a black fill, but it is possible to specify different
settings via
the gp
argument.
The two examples from the previous section demonstrated this idea.
In the left image, gp=gpar(col="black")
is used to stroke the
outline of the brush (the brush is very thin so it is very hard
to see in isolation when it is only filled). In the right image,
gp=gpar(fill=NA)
is used to override the default;
this means that the brush is not filled, but its outline is stroked.
The functions in 'vwline' follow the pattern of normal 'grid'
functions; there is a grid.*
version and a
*Grob
version. Both create a grob to represent a
variable-width line, but only the first one draws anything.
Because we have a grob representing the line, we can do things
like edit the line with grid.edit
and export the
line with 'gridSVG'. The
'vwline' package also adds the ability to query a
variable-width line for points on its boundary. This can be
useful for drawing one variable-width line relative to another.
The code below creates a "vwXsplineGrob", draws it, then calculates five evenly-spaced locations along both edges of the grob. The result is a list of locations for both the left and right edge (see the left image below; the locations along the left edge are shown as red dots), plus a set of tangents at each location (the tangents are used to show perpendicular blue lines).
Both left and right edges are traversed in the same order as the control points (in this case, from left to right across the image), but we can reverse that direction. The code below calculates three points 1cm apart from the start of the left edge (red dots in the right image below) and three points 1cm apart from the end of the left edge (green dots in the right image below).
The edgePoints
function works as described above
for vwcurve
and vwXspline
.
However, the calculation of edge points is a little different for
vwline
, brushXspline
, and
offsetXspline
because in those cases the boundary of the overall line
is not split nicely into separate "left" and "right" edges.
In order to control where to start on the boundary, we
must specify an "origin" and the nearest point on the boundary
to that origin
becomes location 0. By default, the boundary is traversed
anticlockwise, but again we can reverse that if desired.
The code below calculates three points 1cm apart
starting from the location closest to the centre left side of the image
and travelling clockwise around the boundary
(red dots in the right image below), and
three points 1cm apart starting from the location closest to the
centre right side of the image and travelling anticlockwise around
the boundary
(green dots in the right image below).
Some complications arise when a variable-width line intersects
with itself. The variable-width line is drawn as a filled shape,
and if the shape intersects with itself there are different ways
to decide which parts of the shape to fill.
An example of this situation for vwXspline
is shown below.
The main line crosses itself, which means that the shape that
we generate to fill is self-intersecting (see the left image).
By default, the shape is filled as a path using the "non-zero
winding" rule (see the middle image). The render
argument can be used to change to, for example, filling the path
using the "even-odd" rule (see the right image).
The situation is different for brushXspline
because
the shape that we calculate is the union of a whole lot of brush
shapes. In the example below, the shape that is produced is a path
with a hole in it (see the left image). Again, by default, the
path is filled using "non-zero winding" (middle image), but we can
change that if we wish, for example, we can treat the shape as
a polygon rather than a path (right image).
The above example also demonstrates that
when we draw a self-intersecting, variable-width,
brushed line, we get multiple boundary lines.
If we wish to calculate edge points on the boundary
of such a line, we can specify which of the boundary
lines we want to work with (using the which
argument
to edgePoints
).
A subtler version of this self-intersection issue occurs when the boundary of a variable-width line intersects with itself, even though the main line does not. This occurs when the main line has tight corners and/or the width of the line is changing rapidly.
The result for vwXspline
, as shown in the example below,
is a loop in the boundary (left image). The default rendering handles this
problem (middle image), though interesting effects can be had
by altering the rendering (right image).
This issue does not arise for brushXspline
because
the union of brushes cannot create holes in the boundary ...
... however, it is possible to create some unusual shapes if the line
width changes rapidly relative to the line length. In these cases,
the offsetXspline
function will behave more robustly.
All functions have a debug
argument that allows the
addition of graphics debugging information to be drawn on the
variable-width line.
When setting this to TRUE, it is usually useful to also
set gp=gpar(col="black") (so that the line is not filled black,
which would obscure most of the debugging information).
This section demonstrates a range of interesting results that can be achieved with variable-width lines. Only an indication of the required code is given in each case; the full code can be found in the source XML files (see the Resources Section).
The first examples demonstrate the use of an abrupt change in width (to produce arrow heads).
The next examples emulate the examples described in the discussion by Doug Schepers.
The next example demonstrates the use of tangents at boundary
points to arrange a set of variable-width lines along the
boundary of one larger variable-width line. The leg
function demonstrates the usefulness of defining a base
line within a viewport, so that variations of the base line
can be drawn conveniently at a variety of locations, angles, and sizes.
The next example demonstrates adding a gradient fill to a variable-width line with the 'gridSVG' package.
The next example shows brush spacing being used to place arrows part way along a curve
The next example provides the code for the infinity symbol from the start of this document.
The final example shows how the variable-width lines might be
useful in a plot. This is of course based on the data for Minard's
map. This demonstrates the use of stepWidth=TRUE
and linejoin="mitre"
in the vwline
function,
plus, for the return journey,
unequal line widths to the left and right.
The 'vwline' package provides several different convenience functions for generating shapes that represent a curved line with a varying width. These functions make it easy to produce a variety of shapes that would otherwise be arduous to construct with standard R graphics. The hope is that this will help to reduce the temptation for users to manually touch up images outside of R. In other words, the hope is that users will find it easier to construct images purely using (R) code.
In addition to the convenience, there are, to the author's knowledge, two novel contributions within the package. One is a general algorithm for producing line endings and line joins for variable-width curves and the other is code for general offset curves for variable-width X-splines. Additional documents are planned to describe these contributions in greater detail.
The first major drawback to the functions in the 'vwline' package
is speed.
The only concession to efficiency that has been made is to
set the byte-compile flag in the 'vwline' package. This is
particularly aimed at the code underlying the offsetXspline
package, which involves naive and unoptimised functions for calculating
offset X-spline curves.
It is also the case that the functions in 'vwline' are designed only to
produce a single line per function call, so drawing a large number
of variable-width lines will be even slower.
Another weakness of the 'vwline' package is that most functions rely on
a heuristic/numerical approach,
so may be vulnerable to edge cases and numerical instability. Only the
offsetXspline
function is based on an analytical approach
that should be free of those problems.
Finally, the lines drawn by 'vwline' are not really lines, but polygons (or paths). With real lines, in addition to line width, it is possible to control several other features including line endings (butt, square, and round), line joins (round, bevel, and mitre), and line types (dashed, dotted, etc). Although support is provided for line endings and line joins for most functions in 'vwline', there is currently no support for line types.
The examples and discussion in this document relate to 'vwline' version 0.1.
This document was generated within a Docker container (see Resources section below).
This appendix provides a brief review of
X-splines. An understanding of how to control the shape of
an X-spline is useful for both defining the main curve
of a variable-width line (for vwXspline
,
brushXspline
, and offsetXspline
)
and for defining the width of the line (for
brushXspline
and offsetXspline
).
X-splines are a family of curves, where the final curve is based on a set of control points. The curve approximates or interpolates the control points based on a single "shape" parameter. In the three examples below, the control points are drawn as dark grey circles connected by dashed lines; the top X-spline has a shape parameter of 1 (approximation), the middle X-spline has shape 0 (straight line segments), and the bottom X-spline has shape -1 (interpolation). The shape parameter can vary anywhere between -1 and 1.
It is also possible to specify a different shape for each control point (though the end control points must be zero). The second control point below has a shape of 0 and the third control point has a shape of 1.
Approximating X-splines produce nicer smooth curves, but do not pass through control points (except at the ends; see the top line below). Interpolating X-splines go through the control points, but can produce "bumpy" curves (see the middle line). An approximating X-spline with three collinear control points will pass through the central of the three control points (see the bottom line).
Using a not-quite-zero shape can produce a curve that appears to have sharp corners. The upper line below has shape 0; the lower line has shape 0.1.
By default, the first and last control points are repeated, which ensures that the X-spline starts and ends at the first and last x/y locations. This can be turned off, in which case the line only starts and ends alongside the second and second-to-last control point (see the middle image below). Instead of repeating the first and last control points, we can add extra control points that extend in the direction of the first and last control segment, thus ensuring that the line ends at the "first" and "last" control point, but producing a slightly more rounded curve (see the right image below).
Here is an example that shows development of an X-spline that combines smooth approximation with hitting the maximum, with a nice end asymptote.
As a convenience, when specifying the main curve for
vwXspline
or offsetXspline
, the
repEnds
argument also accepts the special value
"extend"
, which means that additional control points
are automatically added (with shape 0) that extend the direction
of the first and last control segments.
This document
is licensed under a Creative
Commons Attribution 4.0 International License.