Variable-Width Lines in R

by Paul Murrell http://orcid.org/0000-0002-3224-8858


Creative Commons License
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.

Table of Contents:

Introduction

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.

Method 1: Offsetting points

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:

  1. The centre of the line is specified by x/y locations; the line is a series of straight line segments.
  2. A perpendicular is calculated at each point on the line; the angle of the perpendicular is based on the slopes of the line segments either side of the point (or just the following/preceding line segment at the start/end of the line).
  3. A "left" border is generated by connecting all left ends of the perpendiculars (where left is defined as if we are moving along the line in the order of the x/y locations). Similar for the "right" border.
  4. A polygon is generated by combining the left border with the reversed right border.

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.

Method 2: Offsetting control points

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:

  1. The centre of the line is specified by x/y control points; the line approximates the control points.
  2. A perpendicular is calculated at each control point - the angle of the perpendicular is based on the slopes of the line segments connecting neighbouring control points - and a new control point is added at each end of the perpendicular.
  3. A "left" border is generated as an X-spline based on the left control points (where left is defined as if we are moving between the control points in the order of the x/y locations). Similar for the "right" border.
  4. A polygon is generated by combining the left border with the reversed right border.

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.

Method 3: Offsetting line segments

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).

  1. The centre of the line is specified by x/y locations; the line is a series of straight line segments.
  2. A perpendicular is calculated at both ends of every line segment, using the specified width value for each location. This produces a series of thick segments.
  3. A "left" border is generated by traversing the left edge of each thick line segment (where left is defined as if we are moving along the line in the order of the x/y locations). Gaps at the outsides of corners are spanned using a linejoin style (see Line joins). The insides of corners are resolved by joining the end of the edge of segment i with the corner location then with the start of the edge of segment i + 1. Similar for the "right" border.
  4. A polygon is generated by combining the left border with the reversed right border and simplifying the resulting polygon (using 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.

Method 4: Sweeping curves with a brush

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.

  1. The centre of the line is specified by x/y control points; the line approximates the control points.
  2. The centre of the line is "flattened" to a series of short straight line segments.
  3. The brush is placed at each vertex on the flattened line, with the brush angled perpendicular to the flattened line and the brush is scaled based on the width of the line at each vertex.
  4. A shape is generated from the convex hull of the first pair pair of brushes.
  5. This is repeated for each consecutive pair of brushes.
  6. A polygon is generated by combining the convex hulls using a union operation.

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.

Method 5: Offsetting curves

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.

  1. The centre of the line is specified by x/y control points; the line approximates the control points.
  2. The centre of the line is "flattened" to a series of short straight line segments.
  3. Perpendiculars are found by evaluating the unit normal and the line width at each vertex (the end of the perpendicular is the vertex plus the unit normal multiplied by the width).
  4. A "left" border is generated by connecting all left ends of the perpendiculars (where left is defined as if we are moving along the line in the order of the x/y locations). Similar for the "right" border.
  5. A polygon is generated by combining the left border with the reversed right border and simplifying the resulting polygon (using 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).

Fine tuning variable-width lines

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.

Specifying line width

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.

Non-perpendicular widths

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).

Line endings

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.

Line joins

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.

Specifying brushes

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.

Closed lines

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.

Brush spacing

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.

Graphical parameters

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.

Querying variable-width lines

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).

Self-intersecting lines

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.

Debugging mode

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).

A gallery of variable-width lines

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.

Discussion

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.

Weaknesses

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.

Technical requirements

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).

Resources

References

Appendix: X-splines

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.


Creative Commons License
This document is licensed under a Creative Commons Attribution 4.0 International License.