3.3 Composing diagrams
The diagrams
framework is fundamentally compositional: complex
diagrams are created by combining simpler diagrams in various ways.
Many of the combination methods discussed in this section are defined
in Diagrams.Combinators
.
Superimposing diagrams with atop
The most fundamental way to combine two diagrams is to place one on
top of the other with atop
. The diagram d1 `atop` d2
is formed
by placing d1
's local origin on top of d2
's local origin; that is,
by identifying their local vector spaces.
> example = circle 1 `atop` square (sqrt 2)
As noted before, diagrams form a monoid with composition given by
superposition. atop
is simply a synonym for mappend
(or (<>)
),
specialized to two dimensions.
This also means that a list of diagrams can be stacked with mconcat
;
that is, mconcat [d1, d2, d3, ...]
is the diagram with d1
on top
of d2
on top of d3
on top of...
> example = mconcat [ circle 0.1 # fc green
> , triangle 1 # scale 0.4 # fc yellow
> , square 1 # fc blue
> , circle 1 # fc red
> ]
Juxtaposing diagrams
Fundamentally, atop
is actually the only way to compose diagrams;
however, there are a number of other combining methods (all ultimately
implemented in terms of atop
) provided for convenience.
Two diagrams can be placed next to each other using beside
. The
first argument to beside
is a vector specifying a direction. The
second and third arguments are diagrams, which are placed next to each
other so that the vector points from the first diagram to the second.
> example = beside (r2 (20,30))
> (circle 1 # fc orange)
> (circle 1.5 # fc purple)
> # showOrigin
As can be seen from the above example, the length of the vector
makes no difference, only its direction is taken into account. (To
place diagrams at a certain fixed distance from each other, see
cat'
.) As can also be seen, the local origin of the new, combined
diagram is the same as the local origin of the first diagram. This
makes beside v
associative, so diagrams under beside v
form a
semigroup. In fact, they form a monoid, since mempty
is a left and
right identity for beside v
, as can be seen in the example below:
> example = hsep 1 . map showOrigin
> $ [ d, mempty ||| d, d ||| mempty ]
> where d = square 1
In older versions of diagrams
, the local origin of the combined
diagram was at the point of tangency between the two diagrams. To
recover the old behavior, simply perform an alignment on the first
diagram in the same direction as the argument to beside
before
combining (see Alignment):
> example = beside (r2 (20,30))
> (circle 1 # fc orange # align (r2 (20,30)))
> (circle 1.5 # fc purple)
> # showOrigin
If you want to place two diagrams next to each other using the local
origin of the second diagram, you can use something like beside' =
flip . beside . negated
, that is, use a vector in the opposite
direction and give the diagrams in the other order.
Since placing diagrams next to one another horizontally and vertically
is quite common, special combinators are provided for convenience.
(|||)
and (===)
are specializations of beside
which juxtapose
diagrams in the \(x\)- and \(y\)-directions, respectively.
> d1 = circle 1 # fc red
> d2 = square 1 # fc blue
> example = (d1 ||| d2) ||| strutX 3 ||| ( d1
> ===
> d2 )
Juxtaposing without composing
Sometimes, one may wish to position a diagram next to another
diagram without actually composing them. This can be accomplished
with the juxtapose
function. In particular, juxtapose v d1 d2
returns a modified version of d2
which has been translated to be
next to d1
in the direction of v
. (In fact, beside
itself is
implemented as a call to juxtapose
followed by a call to (<>)
.)
> d1 = juxtapose unitX (square 1) (circle 1 # fc red)
> d2 = juxtapose (unitX ^+^ unitY) (square 1) (circle 1 # fc green)
> d3 = juxtapose unitY (square 1) (circle 1 # fc blue)
> example = circles ||| strutX 1 ||| (circles <> square 1)
> where circles = mconcat [d1, d2, d3]
See envelopes and local vector spaces for more information on what
"next to" means, and Envelopes for information on
functions available for manipulating envelopes. To learn about how
envelopes are implemented, see the core library reference.
Concatenating diagrams
We have already seen one way to combine a list of diagrams, using
mconcat
to stack them. Several other methods for combining lists of
diagrams are also provided in Diagrams.Combinators
.
The simplest method of combining multiple diagrams is position
,
which takes a list of diagrams paired with points, and places the
local origin of each diagram at the indicated point.
> example = position (zip (map mkPoint [-3, -2.8 .. 3]) (repeat spot))
> where spot = circle 0.2 # fc black
> mkPoint x = p2 (x,x*x)
cat
is an iterated version of beside
, which takes a direction
vector and a list of diagrams, laying out the diagrams beside one
another in a row. The local origins of the subdiagrams will be placed
along a straight line in the direction of the given vector, and the
local origin of the first diagram in the list will be used as the
local origin of the final result.
> example = cat (r2 (2, -1)) (map p [3..8]) # showOrigin
> where p n = regPoly n 1
Semantically, cat v === foldr (beside v) mempty
, although the actual
implementation of cat
uses a more efficient balanced fold.
For more control over the way in which the diagrams are laid out, use
cat'
, a variant of cat
which also takes a CatOpts
record. See
the documentation for cat'
and CatOpts
to learn about the various
possibilities.
> example = cat' (r2 (2,-1)) (with & catMethod .~ Distrib & sep .~ 2 ) (map p [3..8])
> where p n = regPoly n 1 # scale (1 + fromIntegral n/4)
> # showOrigin
For convenience, Diagrams.TwoD.Combinators
also provides
hcat
, hcat'
, vcat
, and vcat'
, variants of cat
and cat'
which concatenate diagrams horizontally and vertically. In addition,
since using hcat'
or vcat'
with some separation tends to be
common, hsep
and vsep
are provided as short synonyms; that is,
hsep s = hcat' (with & sep .~ s)
, and similarly for vsep
.
> example = hsep 0.2 (map square [0.3, 0.7 .. 2])
Finally, appends
is like an iterated variant of beside
, with the
important difference that multiple diagrams are placed next to a
single central diagram without reference to one another; simply
iterating beside
causes each of the previously appended diagrams to
be taken into account when deciding where to place the next one. Of
course, appends
is implemented in terms of juxtapose
(see
Juxtaposing without composing).
> c = circle 1
> dirs = iterate (rotateBy (1/7)) unitX
> cdirs = zip dirs (replicate 7 c)
> example1 = appends c cdirs
> example2 = foldl (\a (v,b) -> beside v a b) c cdirs
> example = example1 ||| strutX 3 ||| example2
3.4 Modifying diagrams
Attributes and styles
Every diagram has a style which is an arbitrary collection of
attributes. This section will describe some of the default
attributes which are provided by the diagrams
library and
recognized by most backends. However, you can easily create your own
attributes as well; for details, see the core library reference.
In many examples, you will see attributes applied to diagrams using
the (#)
operator. Keep in mind that there is nothing special about
this operator as far as attributes are concerned. It is merely
backwards function application, which is used for attributes since it
often reads better to have the main diagram come first, followed by
modifications to its attributes. See Postfix transformation.
In general, inner attributes (that is, attributes applied earlier)
override outer ones. Note, however, that this is not a requirement.
Each attribute may define its own specific method for combining
multiple values. Again, see the core library reference for more
details.
Most of the attributes discussed in this section are defined in
Diagrams.TwoD.Attributes
.
Texture
Two-dimensional diagrams can be filled and stroked with a Texture
. A
Texture
can be either a solid color, a linear gradient or a radial
gradient. Not all backends support gradients, in particular gradients are
supported by the SVG, Cairo, and Rasterific backends (see Rendering backends).
Future releases should also support patterns as textures. The data type
for a texture is
> data Texture = SC SomeColor | LG LGradient | RG RGradient
and Prism
s _SC
, _LG
, _RG
are provided for access.
Color and Opacity
The color used to stroke the paths can be set with the lc
(line color)
function and the color used to fill them with the fc
(fill color) function.
> example = circle 0.2 # lc purple # fc yellow
By default, diagrams use a black line color and a completely
transparent fill color.
Colors themselves are handled by the colour
package, which
provides a large set of predefined color names as well as many more
sophisticated color operations; see its documentation for more
information. The colour
package uses a different type for
colors with an alpha channel (i.e. transparency). To make use of
transparent colors you can use lcA
and fcA
. The palette
package
provides additional sets of colors and algorithms for creating harmonious
color combinations.
> import Data.Colour (withOpacity)
> import Data.Colour.Palette.BrewerSet
>
> blues = map (blue `withOpacity`) [0.1, 0.2 .. 1.0]
> alphaEx = hcat' (with & catMethod .~ Distrib & sep .~ 1 )
> (zipWith fcA blues (repeat (circle 1)))
>
> colors = brewerSet Pastel1 9
> paletteEx = hsep 0.3 (zipWith fc colors (repeat (rect 0.5 1 # lw none)))
>
> example = vsep 1 ([alphaEx, paletteEx] # map centerX)
Another source of predefined color names is the
Diagrams.Color.XKCD
module, containing over 900 common names for
colors as determined by the XKCD color name survey.
> import Diagrams.Color.XKCD
>
> colors = [booger, poisonGreen, cinnamon, petrol, vibrantPurple]
> example = hcat (zipWith fcA colors (repeat (circle 1 # lw none)))
Transparency can also be tweaked with the Opacity
attribute, which
sets the opacity/transparency of a diagram as a whole. Applying
opacity p
to a diagram, where p
is a value between 0
and 1
,
results in a diagram p
times as opaque.
> s c = square 1 # fc c
> reds = (s darkred ||| s red) === (s pink ||| s indianred)
> example = hsep 1 . take 4 . iterate (opacity 0.7) $ reds
Some backends support setting fill and stroke opacities separately,
with fillOpacity
and strokeOpacity
.
Grouped opacity can be applied using the opacityGroup
annotation,
which is currently supported by the diagrams-svg
,
diagrams-pgf
, and (as of version 1.3.1) the
diagrams-rasterific
backends. In the example to the left
below, the section where the two transparent circles overlap is
darker, just as if e.g. two circles made out of colored cellophane
were overlapped. If this documentation was compiled with a backend
that supports opacity grouping (e.g. Rasterific or SVG), then the
example on the right shows two transparent circles without a darker
section where they overlap—the transparency has been applied to the
group of diagrams as a whole, as if it were a single piece of
cellophane cut in the shape of overlapping circles.
> cir = circle 1 # lw none # fc red
> overlap = (cir <> cir # translateX 1)
>
> example = hsep 1 [ overlap # opacity 0.3, overlap # opacityGroup 0.3 ]
> # centerX
> <> rect 9 0.1 # fc lightblue # lw none
To "set the background color" of a diagram, use the bg
function—which does not actually set any attributes, but simply
superimposes the diagram on top of a bounding rectangle of the given
color. The bgFrame
function is similar but the background is expanded
to frame the diagram by a specified amount.
> t = regPoly 3 1
>
> example = hsep 0.2 [t, t # bg orange, t # bgFrame 0.1 orange]
Linear Gradients
A linear gradient must have a list of color stops, a starting point, an ending point,
a transformation and a spread method. Color stops are pairs of (color, fraction) where
the fraction is usually between 0 and 1 that are mapped onto the start and end
points. The starting point and endping point are
specified in local coordinates. Typically the transformation starts as the identity
transform mempty
and records any transformations that are applied to the object
using the gradient. The spread method defines how space beyond the starting and
ending points should be handled: GradPad
will fill the space with the final stop
color, GradRepeat
will restart the gradient, and GradReflect
will restart the
gradient but with the stops reversed. This is the data type for a linear gradient:
> data LGradient n = LGradient
> { _lGradStops :: [GradientStop n]
> , _lGradStart :: P2 n,
> , _lGradEnd :: P2 n,
> , _lGradTrans :: T2 n,
> , _lGradSpreadMethod :: SpreadMethod
> }
Lenses are provided to access the record fields. In addition the
functions mkStops
taking a list of triples (color, fraction,
opacity) and mkLinearGradient
which takes a list of stops, a start
and end point, and a spread method and creates a Texture
are
provided for convenience. In this example we demonstrate how to make
linear gradients with the mkLinearGradient
functions and how to
adjust it using the lenses and prisms.
> stops = mkStops [(gray, 0, 1), (white, 0.5, 1), (purple, 1, 1)]
> gradient = mkLinearGradient stops ((-0.5) ^& 0) (0.5 ^& 0) GradPad
> sq1 = square 1 # fillTexture gradient
> sq2 = square 1 # fillTexture (gradient & _LG . lGradSpreadMethod .~ GradRepeat
> & _LG . lGradStart .~ (-0.1) ^& 0
> & _LG . lGradEnd .~ 0.1 ^& 0
> )
> sq3 = square 1 # fillTexture (gradient & _LG . lGradSpreadMethod .~ GradReflect
> & _LG . lGradStart .~ (-0.1) ^& 0
> & _LG . lGradEnd .~ 0.1 ^& 0
> )
>
> example = hsep 0.25 [sq1, sq2, sq3]
Here we apply the gradient to the stroke only and give it starting and
ending points towards the corners.
> stops = mkStops [(teal, 0, 1), (orange, 1, 1)]
> gradient = mkLinearGradient stops ((-1) ^& (-1)) (1 ^& 1) GradPad
> example = rect 3 1 # lineTexture gradient # lwO 15 # fc black # opacity 0.75
Radial Gradients
Radial gradients are similar, only they begin at the perimeter of an inner cirlce and
end at the perimeter of an outer circle.
> data RGradient n = RGradient
> { _rGradStops :: [GradientStop n]
> , _rGradCenter0 :: P2 n
> , _rGradRadius0 :: n
> , _rGradCenter1 :: P2 n
> , _rGradRadius1 :: n
> , _rGradTrans :: T2 n
> , _rGradSpreadMethod :: SpreadMethod }
Where radius and center 0 are for the inner circle, and 1 for the outer circle.
In this example we place the inner circle off center and place a circle filled
with the radial gradient on top of a rectangle filled with a linear gradient
to create a 3D effect.
> radial = mkRadialGradient (mkStops [(white,0,1), (black,1,1)])
> ((-0.15) ^& (0.15)) 0.06 (0 ^& 0) 0.5
> GradPad
>
> linear = mkLinearGradient (mkStops [(black,0,1), (white,1,1)])
> (0 ^& (-0.5)) (0 ^& 0.5)
> GradPad
>
> example = circle 0.35 # fillTexture radial # lw none
> <> rect 2 1 # fillTexture linear # lw none
Line width
Line width is actually more subtle than you might think. Suppose you
create a diagram consisting of a square, and another square twice as
large next to it (using scale 2
). How should they be drawn? Should
the lines be the same width, or should the larger square use a line
twice as thick? (Note that similar questions also come up when
considering the dashing style used to draw some shapes—should the
size of the dashes scale with transformations applied to the shapes,
or not?) diagrams
allows the user to decide, using Measure Double
values to specify things like line width (see Measurement units).
In many situations, it is desirable to have lines drawn in a uniform
way, regardless of any scaling applied to shapes. This is what
happens with line widths measured in global
, normalized
or
output
units, as in the following example:
> example = hcat
> [ square 1
> , square 1 # scale 2
> , circle 1 # scaleX 3
> ]
> # dashingN [0.03,0.03] 0
> # lwN 0.01
For line widths that scale along with a diagram, use local
; in this
case line widths will be scaled in proportion to the geometeric
average of the scaling transformations applied to the diagram.
The LineWidth
attribute is used to alter the width with which
paths are stroked. The most general functions that can be used to set
the line width are lineWidth
and its synonym lw
, which take an
argument of type Measure V2 n
. Since typing things like lineWidth
(normalized 0.01)
is cumbersome, there are also shortcuts provided:
lwG
, lwN
, lwO
, and lwL
all take an argument of type Double
and wrap it in global
, normalized
, output
and local
,
respectively.
There are also predefined Measure n
values with intuitive names,
namely, ultraThin
, veryThin
, thin
, medium
, thick
,
veryThick
, ultraThick
, and none
(the default is medium
), which
should often suffice for setting the line width.
> line = strokeT . fromOffsets $ [unitX]
> example = vcat' (with & sep .~ 0.1)
> [line # lw w | w <- [ultraThin, veryThin, thin,
> medium, thick, veryThick, ultraThick]]
In the above example, there is no discernible difference between
ultraThin
and veryThin
(depending on the resolution of your
display you may not see any difference with thin
either); these
names all describe normalized
measurements with a physical lower
bound, so the physical width of the resulting lines depends on the
physical size of the rendered diagram. At larger rendering sizes the
differences between the smaller widths become apparent.
Note that line width does not affect the envelope of diagrams at all.
To stroke a line "internally", turning it into a Path
value
enclosing the stroked area (which does contribute to the envelope),
you can use one of the functions described in the section Offsets of
segments, trails, and paths.
Other line parameters
Many rendering backends provide some control over the particular way
in which lines are drawn. Currently, diagrams
provides built-in
support for three aspects of line drawing:
lineCap
sets the LineCap
style.
lineJoin
sets the LineJoin
style.
dashing
allows for drawing dashed lines with arbitrary dashing
patterns.
> path = fromVertices (map p2 [(0,0), (1,0.3), (2,0), (2.2,0.3)]) # lwO 20
> example = center . vcat' (with & sep .~ 0.1 )
> $ map (path #)
> [ lineCap LineCapButt . lineJoin LineJoinMiter
> , lineCap LineCapRound . lineJoin LineJoinRound
> , lineCap LineCapSquare . lineJoin LineJoinBevel
> , dashingN [0.03,0.06,0.09,0.03] 0
> ]
The HasStyle
class
Functions such as fc
, lc
, lw
, and lineCap
do not take only
diagrams as arguments. They take any type which is an instance of the
HasStyle
type class. Of course, diagrams themselves are an
instance.
However, the Style
type is also an instance. This is useful in
writing functions which offer the caller flexible control over the
style of generated diagrams. The general pattern is to take a Style
(or several) as an argument, then apply it to a diagram along with
some default attributes:
> myFun style = d # applyStyle style # lc red # ...
> where d = ...
This way, any attributes provided by the user in the style
argument
will override the default attributes specified afterwards.
To call myFun
, a user can construct a Style
by starting with an
empty style (mempty
, since Style
is an instance of Monoid
) and
applying the desired attributes:
> foo = myFun (mempty # fontSize (local 2) # lw none # fc green)
If the type T
is an instance of HasStyle
, then [T]
is also.
This means that you can apply styles uniformly to entire lists of
diagrams at once, which occasionally comes in handy, for example, to
assign a default attribute to all diagrams in a list which do not
already have one:
> example = hcat $
> [circle 1, square 2, triangle 2 # fc yellow, hexagon 1] # fc blue
Likewise, there are HasStyle
instances for pairs, Map
s, Set
s,
and functions.
Static attributes
Diagrams can also have "static attributes" which are applied at a
specific node in the tree representing a diagram. Currently, only
two static attributes are provided:
Hyperlinks are supported only by the SVG backend. To turn a diagram
into a hyperlink, use the href
function.
Transparency grouping via the opacityGroup
function is supported
only by the SVG, PGF and (as of 1.3) Rasterific backends; see Color and Opacity.
More static attributes (for example, node IDs) and wider backend
support may be added in future versions.
2D Transformations
Any diagram can be transformed by applying arbitrary affine
transformations to it. Affine transformations include linear
transformations (rotation, scaling, reflection, shears—anything
which leaves the origin fixed and sends lines to lines) as well as
translations. In the simplified case of the real line, an affine
transformation is any function of the form \(f(x) = mx + b\).
Generalizing to \(d\) dimensions, an affine transformation is a
vector function of the form \(f(\mathbf{v}) = \mathbf{M}\mathbf{v} +
\mathbf{b}\), where \(\mathbf{M}\) is a \(d \times d\)
matrix representing a linear transformation, and \(\mathbf{b}\) is
a \(d\)-dimensional vector representing a translation. More
general, non-affine transformations, including projective
transformations, are referred to in diagrams
as Deformations.
Diagrams.TwoD.Transform
defines a number of common affine
transformations in two-dimensional space. (To construct
transformations more directly, see Diagrams.Core.Transform
.)
Every transformation comes in two variants, a noun form and a verb
form. For example, there are two functions for scaling along the
\(x\)-axis, scalingX
and scaleX
. The noun form (e.g.
scalingX
) constructs a Transformation
value, which can then be
stored in a data structure, passed as an argument, combined with other
transformations, etc., and ultimately applied to a diagram (or other
Transformable
value) with the transform
function. The verb form
directly applies the transformation. The verb form is much more
common (and the documentation below will only discuss verb forms), but
getting one's hands on a first-class Transformation
value can
occasionally be useful.
Both the verb and noun variants of transformations are monoids, and
can be composed with (<>)
. However, the results are quite distinct,
as shown in this example.
> ell = text "L" <> square 1 # lw none
> alpha = 45 @@ deg
>
> dia1 = ell # translateX 2 # rotate alpha
> dia2 = ell # ( rotate alpha <> translateX 2 )
> dia3 = ell # transform ( rotation alpha <> translationX 2 )
>
> example =
> hsep 2
> [ (dia1 <> orig)
> , vrule 4
> , (dia2 <> orig)
> , vrule 4
> , (dia3 <> orig)
> ]
> where
> orig = circle 0.05 # fc red # lw none
dia1
is the intended result: a character L translated along the X axis,
and then rotated 45 degrees around the origin.
dia2
shows the result of naively composing the verb versions of
the transformations: a superposition of a rotated L and a
translated L. To understand this, consider that (rotate alpha)
is a function, and functions as monoid instances (Monoid m =>
Monoid (a -> m)
) are composed as (f <> g) x = f x <> g x
. To
quote the Typeclassopedia: if a
is a Monoid, then so is the
function type e -> a
for any e
; in particular, g `mappend`
h
is the function which applies both g
and h
to its argument
and then combines the results using the underlying Monoid instance
for a
.
Hence ell # ( rotate alpha <> translateX 2 )
is
the same as the superposition of two diagrams: rotate alpha ell <>
translateX 2 ell
.
dia3
shows how the noun versions can be composed (using the
Monoid
instance for Transformation
) with the intended result.
Rotation
Use rotate
to rotate a diagram counterclockwise by a given angle
about the origin. Since rotate
takes an Angle n
, you must specify an
angle unit, such as rotate (80 @@ deg)
. In the common case that you
wish to rotate by an angle specified as a certain fraction of a
circle, like rotate (1/8 @@ turn)
, you can use rotateBy
instead. rotateBy
takes a Double
argument expressing the number of
turns, so in this example you would only have to write rotateBy
(1/8)
.
You can also use rotateAbout
in the case that you want to rotate
about some point other than the origin.
> eff = text "F" <> square 1 # lw none
> rs = map rotateBy [1/7, 2/7 .. 6/7]
> example = hcat . map (eff #) $ rs
Scaling and reflection
Scaling by a given factor is accomplished with scale
(which scales
uniformly in all directions), scaleX
(which scales along the \(x\)-axis
only), or scaleY
(which scales along the \(y\)-axis only). All of these
can be used both for enlarging (with a factor greater than one) and
shrinking (with a factor less than one). Using a negative factor
results in a reflection (in the case of scaleX
and scaleY
) or a
180-degree rotation (in the case of scale
).
> eff = text "F" <> square 1 # lw none
> ts = [ scale (1/2), id, scale 2, scaleX 2, scaleY 2
> , scale (-1), scaleX (-1), scaleY (-1)
> ]
>
> example = hcat . map (eff #) $ ts
Scaling by zero is forbidden. Let us never speak of it again.
For convenience, reflectX
and reflectY
perform reflection along
the \(x\)- and \(y\)-axes, respectively. Their names can be
confusing (does reflectX
reflect along the \(x\)-axis or
across the \(x\)-axis?) but you can just remember that
reflectX = scaleX (-1)
, and similarly for reflectY
; that is,
reflectQ
affects Q
-coordinates.
reflectXY
swaps the \(x\)- and \(y\)-coordinates, that is,
it reflects across the line \(y = x\). To reflect across any
other line, use reflectAbout
.
> eff = text "F" <> square 1 # lw none
> example = eff
> <> reflectAbout (p2 (0.2,0.2)) (rotateBy (-1/10) xDir) eff
Translation
Translation is achieved with translate
, translateX
, and
translateY
, which should be self-explanatory.
Conjugation
Diagrams.Transform
also exports some useful transformation
utilities which are not specific to two dimensions. The conjugate
function performs conjugation: conjugate t1 t2 == inv t1 <> t2 <>
t1
, that is, it performs t1
, then t2
, then undoes t1
.
underT
performs a transformation using conjugation. It takes as
arguments a function f
as well as a transformation to conjugate by,
and produces a function which performs the transformation, then f
,
then the inverse of the transformation. For example, scaling by a
factor of 2 along the diagonal line \(y = x\) can be accomplished
thus:
> eff = text "F" <> square 1 # lw none
> example = (scaleX 2 `underT` rotation (-1/8 @@ turn)) eff
The letter F is first rotated so that the desired scaling axis lies
along the \(x\)-axis; then scaleX
is performed; then it is rotated back
to its original position.
Note that reflectAbout
and rotateAbout
are implemented using
underT
.
Some functions for producing Iso
s (from the lens
library)
are also provided, which serve a similar purpose to conjugate
and
underT
, but can be more convenient when working in a lens
-y
style. For example, the transformed
function takes a
Transformation
and yields an Iso
between untransformed and
transformed things. movedTo
, movedFrom
, and translated
work
similarly, but specific to translation.
Deformations
The affine transformations represented by Transformation
include the
most commonly used transformations, but occasionally other sorts are
useful. Non-affine transformations are represented by the
Deformation
type. The design is quite similar to that of
Transformation
. A Deformation
is parameterized by the vector
spaces over which it acts: most generally, it may send objects in one
vector space to objects in another. There is a Deformable
type
class with a function deform
, which applies a Deformation
to a
Deformable
value. There is also a function deform'
which takes an
extra tolerance parameter; applying deformations usually involves
approximation.
> wibble :: Deformation V2 V2 Double
> wibble = Deformation $ \p ->
> ((p^._x) + 0.3 * cos ((p ^. _y) * tau)) ^& (p ^. _y)
> -- perturb x-coordinates by the cosine of the y-coordinate
>
> circles :: Path V2 Double
> circles = mconcat . map circle $ [3, 2.6, 2.2]
>
> example :: Diagram B
> example = circles # deform' 0.0001 wibble # strokeP
> # fillRule EvenOdd # fc purple # frame 1
Because the deform
function is so general, type signatures are often
required on both its inputs and results, as in the example above;
otherwise ambiguous type errors are likely to result.
Deformation v v n
is a Monoid
for any vector space v n
. (In
general, Deformation u v n
maps objects with vector space u
to
ones with vector space v
.) New deformations can be formed by
composing two deformations. The composition of an affine
transformation with a Deformation
is also a Deformation
.
asDeformation
converts a Transformation
to an equivalent
Deformation
, "forgetting" the inverse and other extra information
which distinguishes affine transformations.
The very general nature of deformations prevents certain types
from being Deformable
. Because not every Deformation
is
invertible, diagrams cannot be deformed. In general, for two points
\(p\) and \(q\), and a deformation \(D\), there may be no
deformation \(D_v\) such that \(Dp - Dq = D_v(p-q)\). For
this reason, only points and concretely located types are deformable.
Finally, segments are not deformable because the image of the segment
may not be representable by a single segment. The Deformable
instances for trails and paths will approximate each segment by
several segments as necessary. Points, Located
trails, and paths
are all deformable.
Because approximation and subdivision are required for many
Deformable
instances, the type class provides a function deform'
,
which takes the approximation accuracy as its first argument. For
trails and paths, deform
(without a prime) calls deform'
with an
error limit of 0.01 times the object's size.
Diagrams.TwoD.Deform
defines parallel and perspective
projections along the principal axes in 2 dimensions. The below
example projects the vertices of a square orthogonally onto the
\(x\)- and \(y\)-axes, and also using a perspective projection
onto the line \(x = 1\).
> sq = unitSquare # rotateBy (1/17) # translate (3 ^& 2) :: Path V2 Double
> sqPts = concat $ pathVertices sq --XXX dont forget to change back to pathPoints
> marks = repeat . lw none $ circle 0.05
> spots c pts = atPoints pts (marks # fc c)
> connectPoints pts1 pts2
> = zipWith (~~) pts1 pts2
> # mconcat
> # dashingL [0.1, 0.1] 0
> example =
> mconcat
> [ spots blue sqPts
> , strokeP sq
> , spots green (map (deform parallelX0) sqPts)
> , spots green (map (deform parallelY0) sqPts)
> , spots green (map (deform perspectiveX1) sqPts)
> , connectPoints sqPts (map (deform parallelX0) sqPts)
> , connectPoints sqPts (map (deform parallelY0) sqPts)
> , connectPoints sqPts (repeat origin)
> ]
Alignment
Since diagrams are always combined with respect to their local
origins, moving a diagram's local origin affects the way it combines
with others. The position of a diagram's local origin is referred to
as its alignment.
The functions moveOriginBy
and moveOriginTo
are provided for
explicitly moving a diagram's origin, by an absolute amount and to an
absolute location, respectively. moveOriginBy
and translate
are
actually dual, in the sense that
moveOriginBy v === translate (negated v).
This duality comes about since translate
moves a diagram with
respect to its origin, whereas moveOriginBy
moves the origin with
respect to the diagram. Both are provided so that you can use
whichever one corresponds to the most natural point of view in a given
situation, without having to worry about inserting calls to negated
.
Often, however, one wishes to move a diagram's origin with respect to
its "boundary". Here, boundary usually refers to the diagram's
envelope or trace, with envelope being the default (see Envelopes
and Traces for more information). To this end, some general tools
are provided in Diagrams.Align
, and specialized 2D-specific
ones by Diagrams.TwoD.Align
.
Functions like alignT
(align Top) and alignBR
(align Bottom Right)
move the local origin to the edge of the envelope:
> s = square 1 # fc yellow
> example = hsep 0.5
> [ s # showOrigin
> , s # alignT # showOrigin
> , s # alignBR # showOrigin
> ]
There are two things to note about the above example. First, notice
how alignT
and alignBR
move the local origin of the square in the
way you would expect. Second, notice that when placed "next to" each
other using the (|||)
operator (here implicitly via hsep
), the
squares are placed so that their local origins fall on a horizontal
line.
Functions like alignY
allow finer control over the alignment. In
the below example, the origin is moved to a series of locations
interpolating between the bottom and top of the square:
> s = square 1 # fc yellow
> example = hcat . map showOrigin
> $ zipWith alignY [-1, -0.8 .. 1] (repeat s)
To center an object along an axis we provide the functions centerX
and centerY
. An object can be simultaneously centered along both axes
(actually along all of its basis vectors) using the center
function
(or centerXY
in the specific case of two dimensions).
The align functions have sister functions like snugL
and snugX
that work the same way as alignL
and alignX
. The difference is
that the snug
class of functions use the trace as the boundary
instead of the envelope. For example, here we want to snug a convex
shape (the orange triangle) next to a concave shape (the blue
polygon):
> import Diagrams.TwoD.Align
>
> concave = polygon ( with & polyType .~ PolyPolar [a, b, b, b]
> [ 0.25,1,1,1,1] & polyOrient .~ NoOrient )
> # fc blue # lw none
> where
> a = 1/8 @@ turn
> b = 1/4 @@ turn
>
> convex = polygon (with & polyType .~ PolyPolar [a,b] [0.25, 1, 1]
> & polyOrient .~ NoOrient)
> # fc orange # lw none
> where
> a = 1/8 @@ turn
> b = 3/4 @@ turn
>
> aligned = (concave # center # alignR # showOrigin)
> <> (convex # center # alignL # showOrigin)
>
> snugged = (concave # center # snugR # showOrigin)
> <> (convex # center # snugL # showOrigin)
>
> example = aligned ||| strutX 0.5 ||| snugged
The snugR
function moves the origin of the blue polygon to the
rightmost edge of its trace in the diagram on the right, whereas in
the left diagram the alignR
function puts it at the edge of the
envelope.
Aligned composition
Sometimes, it is desirable to compose some diagrams according to a
certain alignment, but without affecting their local origins. The
composeAligned
function can be used for this purpose. It takes as
arguments an alignment function (such as alignT
or snugL
), a
composition function of type [Diagram] -> Diagram
, and produces a
new composition function which works by first aligning the diagrams
before composing them.
> example = (hsep 2 # composeAligned alignT) (map circle [5,1,3,2])
> # showOrigin
3.5 Trails and paths
Trails and paths are some of the most fundamental tools in
diagrams
. They can be used not only directly to draw things, but
also as guides to help create and position other diagrams.
For additional practice and a more "hands-on" experience learning
about trails and paths, see the trails and paths tutorial.
Segments
The most basic component of trails and paths is a Segment
, which is
some sort of primitive path from one point to another. Segments are
translationally invariant; that is, they have no inherent location,
and applying a translation to a segment has no effect (however, other
sorts of transformations, such as rotations and scales, have the
effect you would expect). In other words, a segment is not a way to
get from some particular point A to another point B; it is a way to
get from wherever you currently happen to be to somewhere else.
Currently, diagrams
supports two types of segment, defined in
Diagrams.Segment
:
A linear segment is simply a straight line, defined by an offset
from its beginning point to its end point; you can construct one
using straight
.
A Bézier segment is a cubic curve defined by an offset from its
beginning to its end, along with two control points; you can
construct one using bezier3
(or bézier3
, if you are feeling
snobby). An example is shown below, with the endpoints shown in red
and the control points in blue. Bézier curves always start off
from the beginning point heading towards the first control point,
and end up at the final point heading away from the last control
point. That is, in any drawing of a Bézier curve like the one
below, the curve will be tangent to the two dotted lines.
> illustrateBézier c1 c2 x2
> = endpt
> <> endpt # translate x2
> <> ctrlpt # translate c1
> <> ctrlpt # translate c2
> <> l1
> <> l2
> <> fromSegments [bézier3 c1 c2 x2]
> where
> dashed = dashingN [0.03,0.03] 0
> endpt = circle 0.05 # fc red # lw none
> ctrlpt = circle 0.05 # fc blue # lw none
> l1 = fromOffsets [c1] # dashed
> l2 = fromOffsets [x2 ^-^ c2] # translate c2 # dashed
>
> x2 = r2 (3,-1) :: V2 Double -- endpoint
> [c1,c2] = map r2 [(1,2), (3,0)] -- control points
>
> example = illustrateBézier c1 c2 x2
Independently of the two types of segments explained above, segments
can be either closed or open. A closed segment has a fixed
endpoint relative to its start. An open segment, on the other hand,
has an endpoint determined by its context; open segments are used to
implement loops (explained in the Trails section below). Most
users should have no need to work with open segments. (For that
matter, most users will have no need to work directly with segments at
all.)
If you look in the Diagrams.Segment
module, you will see quite
a bit of other stuff related to the implementation of trails
(SegMeasure
and so on); this is explained in more detail in the
section Trail and path implementation details.
Functions from the Diagrams.TwoD.Curvature
module can be used
to compute the curvature of segments at various points. In future
releases of diagrams this may be extended to tools for finding the
curvature of trails and paths.
Trails
Trails are defined in Diagrams.Trail
. Informally, you can
think of trails as lists of segments laid end-to-end. Since segments
are translation-invariant, so are trails. More formally, the
semantics of a trail is a continuous (though not necessarily
differentiable) function from the real interval \([0,1]\) to
vectors in some vector space. This section serves as a reference on
trails; for a more hands-on introduction, refer to the Trail and path
tutorial.
There are two types of trail:
A loop, with a type like Trail' Loop v n
, is a trail which forms
a "closed loop", ending at the same place where it started.
Loops in 2D can be filled, as in the example above.
A line, with a type like Trail' Line v n
, is a trail which does
not form a closed loop, that is, it starts in one place and ends
in another.
Actually, a line can in fact happen to end in the same place where
it starts, but even so it is still not considered closed. Lines
have no inside and outside, and are never filled.
Lines are never filled, even when they happen to start and end in
the same place!
Finally, the type Trail
can contain either a line or a loop.
The most important thing to understand about lines, loops, and trails
is how to convert between them.
To convert from a line or a loop to a trail, use wrapLine
or
wrapLoop
(or wrapTrail
, if you don't know or care whether the
parameter is a line or loop).
To convert from a loop to a line, use cutLoop
. This results in a
line which just so happens to end where it starts.
To convert from a line to a loop, there are two choices:
closeLine
adds a new linear segment from the end to the start of
the line.
> almostClosed :: Trail' Line V2 Double
> almostClosed = fromOffsets $ (map r2
> [(2, -1), (-3, -0.5), (-2, 1), (1, 0.5)])
>
> example = pad 1.1 . center . fc orange . hsep 1
> $ [ almostClosed # strokeLine
> , almostClosed # closeLine # strokeLoop
> ]
glueLine
simply modifies the endpoint of the final segment to be
the start of the line. This is most often useful if you have a
line which you know just so happens to end where it starts;
calling closeLine
in such a case would result in the addition of
a gratuitous length-zero segment.
Lines form a monoid under concatenation. For example, below we create
a two-segment line called spoke
and then construct a starburst
path by concatenating a number of rotated copies. Note how we call
glueLine
to turn the starburst into a closed loop, so that we can
fill it (lines cannot be filled). strokeLoop
turns a loop into a
diagram, with the start of the loop at the local origin. (There are
also analogous functions strokeLine
and strokeTrail
.)
> spoke :: Trail' Line V2 Double
> spoke = fromOffsets . map r2 $ [(1,3), (1,-3)]
>
> burst :: Trail' Loop V2 Double
> burst = glueLine . mconcat . take 13 . iterate (rotateBy (-1/13)) $ spoke
>
> example = strokeLoop burst # fc yellow # lw thick # lc orange
For convenience, there is also a monoid instance for Trail
based on
the instance for lines: any loops are first cut with cutLine
, and
the results concatenated. Typically this would be used in a situation
where you know that all your trails actually contain lines.
Loops, on the other hand, have no monoid instance.
To construct a line, loop, or trail, you can use one of the following:
fromOffsets
takes a list of vectors, and turns each one into a
linear segment.
> theLine = fromOffsets (iterateN 5 (rotateBy (1/20)) unitX)
> example = theLine # strokeLine
> # lc blue # lw thick # center # pad 1.1
fromVertices
takes a list of vertices, generating linear segments
between them.
> vertices = map p2 $ [(x,y) | x <- [0,0.2 .. 2], y <- [0,1]]
> example = fromVertices vertices # strokeLine
> # lc red # center # pad 1.1
(~~)
creates a simple linear trail between two points.
cubicSpline
creates a smooth curve passing through a given list of
points; it is described in more detail in the section on Splines.
> vertices = map p2 . init $ [(x,y) | x <- [0,0.5 .. 2], y <- [0,0.2]]
> theLine = cubicSpline False vertices
> example = mconcat (iterateN 6 (rotateBy (-1/6)) theLine)
> # glueLine # strokeLoop
> # lc green # lw veryThick # fc aqua # center # pad 1.1
bspline
creates a smooth curve controlled by a given list of
points; it is also described in more detail in the section on
Splines.
> pts = map p2 (zip [0 .. 8] (cycle [0, 1]))
> example = mconcat
> [ bspline pts
> , mconcat $ map (place (circle 0.1 # fc blue # lw none)) pts
> ]
fromSegments
takes an explicit list of Segment
s, which can
occasionally be useful if, say, you want to generate some Bézier
curves and assemble them into a trail.
All the above functions construct loops by first constructing a line
and then calling glueLine
(see also the below section on
TrailLike).
If you look at the types of these functions, you will note that they
do not, in fact, return just Trail
s: they actually return any type
which is an instance of TrailLike
, which includes lines, loops,
Trail
s, Path
s (to be covered in an upcoming section), Diagram
s,
lists of points, and any of these wrapped in Located
(see below).
See the TrailLike section for more on the TrailLike
class.
For details on other functions provided for manipulating trails, see
the documentation for Diagrams.Trail
. One other function worth
mentioning is explodeTrail
, which turns each segment in a trail into
its own individual Path
. This is useful when you want to construct
a trail but then do different things with its individual segments.
For example, we could construct the same starburst as above but color
the edges individually:
> spoke :: Trail V2 Double
> spoke = fromOffsets . map r2 $ [(1,3), (1,-3)]
>
> burst = mconcat . take 13 . iterate (rotateBy (-1/13)) $ spoke
>
> colors = cycle [aqua, orange, deeppink, blueviolet, crimson, darkgreen]
>
> example = lw thick
> . mconcat
> . zipWith lc colors
> . map strokeLocTrail . explodeTrail
> $ burst `at` origin
(If we wanted to fill the starburst with yellow as before, we would
have to separately draw another copy of the trail with a line width of
zero and fill that; this is left as an exercise for the reader.)
Located
Something of type Located a
consists, essentially, of a value of
type a
paired with a point. In this way, Located
serves to
transform translation-invariant things (such as Segment
s or
Trail
s) into things with a fixed location. A Located Trail
is a
Trail
where we have picked a concrete location for its starting
point, and so on.
The module Diagrams.Located
defines the Located
type and
utilities for working with it:
at
is used to construct Located
values, and is designed to be
used infix, like someTrail `at` somePoint
.
viewLoc
, unLoc
, and loc
can be used to project out the
components of a Located
value.
mapLoc
can be used to apply a function to the value of type a
inside a value of type Located a
. Note that Located
is not a
Functor
, since it is not possible to change the contained type
arbitrarily: mapLoc
does not change the location, and the vector
space associated to the type a
must therefore remain the same.
Much of the utility of having a concrete type for the Located
concept (rather than just passing around values paired with points)
lies in the type class instances we can give to Located
:
HasOrigin
: translating a Located a
simply translates the
associated point, leaving the value of type a
unaffected.
Transformable
: only the linear component of transformations are
applied to the wrapped value (whereas the entire transformation is
applied to the location).
Enveloped
: the envelope of a Located a
is the envelope of the
contained a
, translated to the stored location (and similarly for
Traced
).
The TrailLike
instance is also useful; see TrailLike.
Paths
A Path
, also defined in Diagrams.Path
, is a (possibly empty)
collection of Located Trail
s. Paths of a single trail can be
constructed using the same functions described in the previous
section: fromSegments
, fromOffsets
, fromVertices
, (~~)
, and
cubicSpline
, bspline
.
Path
s also form a Monoid
, but the binary operation is
superposition (just like that of diagrams). Paths with
multiple components can be used, for example, to create shapes with
holes:
> ring :: Path V2 Double
> ring = circle 3 <> (circle 2 # reversePath)
>
> example = ring # strokeP # fc purple
See the section on Fill rules for more information.
strokePath
(alias strokeP
) turns a path into a diagram, just as
strokeTrail
turns a trail into a diagram. (In fact, strokeTrail
really works by first turning the trail into a path and then calling
strokePath
on the result.)
explodePath
, similar to explodeTrail
, turns the segments of a path
into individual paths. Since a path is a collection of trails, each
of which is a sequence of segments, explodePath
actually returns a
list of lists of paths.
For information on other path manipulation functions such as
pathFromTrail
, pathFromLocTrail
, pathPoints
, pathVertices
,
pathOffsets
, scalePath
, and reversePath
, see the Haddock
documentation in Diagrams.Path
.
Vertices vs points
A vertex of a trail or path is defined as a sharp corner, i.e. a
non-differentiable point. This is (mostly) independent of the
implementation of trails and paths. A point, on the other hand,
refers to the join point between two Segment
s, which is specific to
the implementation of trails as collections of Segment
s.
For computing vertices, there are a number of functions like
pathVertices
, trailVertices
, lineVertices
, and loopVertices
.
Each of these also has a primed variant, like trailVertices'
, which
takes an extra argument specifying a tolerance: in practice, where
two segments join, we need some tolerance expressing how close the
slopes of the segments must be in order to consider the join point
differentiable (and hence not a vertex).
For computing points, there are variants pathPoints
, trailPoints
,
linePoints
, and loopPoints
. However, these are (intentionally)
not exported from Diagrams.Prelude
. To use them, import
Diagrams.Path
or Diagrams.Trail
.
In the example below, you can see that a circle has no vertices,
whereas it has four points (exposing the implementation detail that a
circle is constructed out of four Bézier segments; you should not rely
on this!). On the other hand, a hexagon has the six vertices you
would expect.
> import Diagrams.Trail -- for trailPoints
>
> visPoints :: [P2 Double] -> Diagram B
> visPoints pts = atPoints pts (repeat (circle 0.05 # lw none # fc blue))
>
> example = hsep 0.5
> [ circle 1 `beneath` visPoints (trailVertices (circle 1))
> , circle 1 `beneath` visPoints (trailPoints (circle 1))
> , hexagon 1 `beneath` visPoints (trailVertices (hexagon 1))
> ]
Stroking trails and paths
The strokeTrail
and strokePath
functions, which turn trails and paths into
diagrams respectively, have already been mentioned; they are defined
in Diagrams.TwoD.Path
. Both also have primed variants,
strokeTrail'
and strokePath'
, which take a record of StrokeOpts
.
Currently, StrokeOpts
has two fields:
vertexNames
takes a list of lists of names, and zips each list
with a component of the path, creating point subdiagrams (using
pointDiagram
) associated with the names. This means that the
names can be used to later refer to the locations of the path
vertices (see Named subdiagrams). In the case of strokeTrail'
,
only the first list is used.
By default, vertexNames
is an empty list.
queryFillRule
specifies the fill rule (see Fill rules) used to
determine which points are inside the diagram, for the purposes of
its query (see Using queries). Note that it does not affect
how the diagram is actually drawn; for that, use the fillRule
function. (This is not exactly a feature, but for various technical
reasons it is not at all obvious how to have this field actually
affect both the query and the rendering of the diagram.)
By default, queryFillRule
is set to Winding
.
There is also a method stroke
, which takes as input any type which
is an instance of ToPath
, a type class with a single method:
> toPath :: (Metric (V t), OrderedField (N t))
> => t -> Path (V t) (N t)
Calling stroke
can sometimes produce errors complaining of an
ambiguous type, which can happen if stroke
is called on something
which is itself polymorphic (e.g. because it can be any instance of
TrailLike
). The solution in this case is to use type-specific
stroking functions like strokePath
, strokeTrail
, strokeLocLine
,
etc. See the ToPath reference for more information.
Offsets of segments, trails, and paths
Given a segment and an offset radius \(r\) we can make an offset segment
that is the distance \(r\) from the original segment. More specifically,
you can think of the offset as the curve traced by the end of a vector of
length \(r\) perpendicular to the original curve. This vector goes on the
right of the curve for a positive radius and on the left for a negative radius.
> import Diagrams.TwoD.Offset
>
> example :: Diagram B
> example = hsep 1 $ map f
> [ straight p
> , bézier3 (r2 (0,0.5)) (r2 (1,0.5)) p
> ]
> where
> p = r2 (1,1)
> f :: Segment Closed V2 Double -> Diagram B
> f s = fromSegments [s]
> <> offsetSegment 0.1 0.2 s # strokeLocTrail # lc blue
Animate tracing an offset?
For a straight segment this will clearly be a parallel straight line with
\(r\) as the distance between the lines. For an counter-clockwise arc of
radius \(R\) the offset will be an arc with the same center, start and end
angles, and radius \(r+R\). Cubic segments present a problem, however.
The offset of a cubic Bézier curve could be a higher degree curve. To
accommodate this we approximate the offset with a sequence of segments. We
now have enough details to write the type for offsetSegment
.
> offsetSegment :: Double -> Double -> Segment Closed V2 Double -> Located (Trail V2 Double)
The first parameter to offsetSegment
is an epsilon factor \(\epsilon\).
When the radius is multiplied by \(\epsilon\) we get the maximum allowed
distance a point on the approximate offset can differ from the true offset.
The final parameters are the radius and the segment. The result is a located
trail. It is located because the offset's start will be distance \(r\)
away from the segment start which is the origin.
If we can offset a segment we naturally will want to extend this to offset a
trail. A first approach might be to simply map offsetSegment
over the
segments of a trail. But we quickly notice that if the trail has any sharp
corners, the offset will be disconnected!
> import Diagrams.TwoD.Offset
>
> locatedTrailSegments t = zipWith at (trailSegments (unLoc t)) (trailVertices t)
>
> bindLoc f = join' . mapLoc f
> where
> join' x = let (p,a) = viewLoc x in translate (p .-. origin) a
>
> offsetTrailNaive :: Double -> Double -> Trail V2 Double -> Path V2 Double
> offsetTrailNaive e r = mconcat . map (pathFromLocTrail . bindLoc (offsetSegment e r))
> . locatedTrailSegments . (`at` origin)
>
> example :: Diagram B
> example = (p # strokeTrail <> offsetTrailNaive 0.1 0.3 p # stroke # lc blue)
> # lw thick
> where p = fromVertices . map p2 $ [(0,0), (1,0.3), (2,0), (2.2,0.3)]
First let's consider the outside corner where the adjacent offset segments do
not cross. If we consider sweeping a perpendicular vector along the original
trail we have a problem when we get to a corner. It is not clear what
perpendicular means for that point. One solution is to take all points
distance \(r\) from the corner point. This puts a circle around the corner
of radius \(r\). Better is to just take the portion of that circle that
transitions from what is perpendicular at the end of the first segment to what
is perpendicular at the start of the next. We could also choose to join together
offset segments in other sensible ways. For the choice of join we have the
_offsetJoin
field in the OffsetOpts
record.
> import Diagrams.TwoD.Offset
>
> example :: Diagram B
> example = (p # strokeTrail <> o # strokeLocTrail # lc blue)
> # lw thick
> where
> p = fromVertices . map p2 $ [(0,0), (1,0.3), (2,0), (2.2,0.3)]
> o = offsetTrail' (with & offsetJoin .~ LineJoinRound) 0.3 p
Inside corners are handled in a way that is consistent with outside corners, but
this yields a result that is most likely undesirable. Future versions of Diagrams
will include the ability to clip inside corners with several options for how to
do the clipping.
Update after implementing clipping.
There are other interesting ways we can join segments. We implement the standard
line join styles and will also in the future provide the ability to specify a custom
join.
> import Diagrams.TwoD.Offset
>
> example :: Diagram B
> example = hsep 0.5 $ map f [LineJoinMiter, LineJoinRound, LineJoinBevel]
> where
> f s = p # strokeTrail <> (offsetTrail' (with & offsetJoin .~ s) 0.3 p # strokeLocTrail # lc blue)
> p = fromVertices . map p2 $ [(0,0), (1,0), (0.5,0.7)]
The LineJoinMiter
style in particular can use more information to dictate how
long a miter join can extend. A sharp corner can have a miter join that is an
unbounded distance from the original corner. Usually, however, this long join
is not desired. Diagrams follows the practice of most graphics software and
provides a _offsetMiterLimit
field in the OffsetOpts
record. When the join
would be beyond the miter limit, the join is instead done with a straight line
as in the LineJoinBevel
style. The OffsetOpts
record then has three
parameters:
> data OffsetOpts = OffsetOpts
> { _offsetJoin :: LineJoin
> , _offsetMiterLimit :: Double
> , _offsetEpsilon :: Double
> }
And the type for offsetTrail'
is (offsetTrail
simply uses the Default
instance for OffsetOpts
):
> offsetTrail :: Double -> Located (Trail V2 Double) -> Located (Trail V2 Double)
> offsetTrail' :: OffsetOpts -> Double -> Located (Trail V2 Double) -> Located (Trail V2 Double)
>
> offsetPath :: Double -> Path V2 Double -> Path V2 Double
> offsetPath' :: OffsetOpts -> Double -> Path V2 Double -> Path V2 Double
Notice this takes a Trail V2 Double
which means it works for both Trail' Line V2 Double
and Trail' Loop V2 Double
. The second parameter is the radius for the offset. A
negative radius gives a Line
on the right of the curve, or a Loop
inside a
counter-clockwise Loop
. For offsetPath
we can simply map offsetTrail
over the trails in the path in the most natural way.
Expand segments, trails, and paths
Expanding is just like the offset, but instead of producing a curve that
follows one side we follow both sides and produce a Loop
that can be filled
representing all the area within a radius \(r\) of the original curve.
In addition to specifying how segments are joined, we now have to specify the
transition from the offset on one side of a curve to the other side of a curve.
This is given by the LineCap
.
> data ExpandOpts = ExpandOpts
> { _expandJoin :: LineJoin
> , _expandMiterLimit :: Double
> , _expandCap :: LineCap
> , _expandEpsilon :: Double
> }
>
> expandTrail :: Double -> Located (Trail V2 Double) -> Path V2 Double
> expandTrail' :: ExpandOpts -> Double -> Located (Trail V2 Double) -> Path V2 Double
>
> expandPath :: Double -> Path V2 Double -> Path V2 Double
> expandPath' :: ExpandOpts -> Double -> Path V2 Double -> Path V2 Double
The functionality follows closely to the offset functions, but notice that
the result of expandTrail
is a Path V2 Double
where offsetTrail
resulted in
a Located (Trail V2 Double)
. This is because an expanded Loop
will be a pair
of loops, one inside and one outside. To express this we need a Path
.
> import Diagrams.TwoD.Offset
>
> example :: Diagram B
> example = (p # strokeTrail # lw veryThick # lc white <> e # strokePath # lw none # fc blue)
> where
> p = fromVertices . map p2 $ [(0,0), (1,0.3), (2,0), (2.2,0.3)]
> e = expandTrail' opts 0.3 p
> opts = with & expandJoin .~ LineJoinRound
> & expandCap .~ LineCapRound
As long as the expanded path is filled with the winding fill rule we
do not need to worry about having clipping for inside corners. It
works out that the extra loop in the rounded line join will match with
the outside corner. We currently implement all the LineCap
styles,
and plan to support custom styles in future releases.
> import Diagrams.TwoD.Offset
>
> example :: Diagram B
> example = hsep 0.5 $ map f [LineCapButt, LineCapRound, LineCapSquare]
> where
> f s = p # strokeTrail # lw veryThick # lc white
> <> expandTrail' (opts s) 0.3 p # stroke # lw none # fc blue
> p = fromVertices . map p2 $ [(0,0), (1,0), (0.5,0.7)]
> opts s = with & expandJoin .~ LineJoinRound
> & expandCap .~ s
The TrailLike
class
As you may have noticed by now, a large class of functions in the
standard library—such as square
, polygon
, fromVertices
, and so
on—generate not just diagrams, but any type which is an instance
of the TrailLike
type class.
The TrailLike
type class, defined in Diagrams.TrailLike
, has
only a single method, trailLike
:
> trailLike :: Located (Trail (V t) (N t)) -> t
That is, a trail-like thing is anything which can be constructed from
a Located Trail
.
There are quite a few instances of TrailLike
:
Trail
: this instance simply throws away the location.
Trail' Line
: throw away the location, and perform cutLoop
if
necessary. For example, circle 3 :: Trail' Line V2 Double
is an open \(360^\circ\)
circular arc.
Trail' Loop
: throw away the location, and perform glueLine
if
necessary.
Path
: construct a path with a single component.
Diagram b
: as long as the backend b
knows how to render
paths, trailLike
can construct a diagram by stroking the generated
single-component path.
[Point v]
: this instance generates the vertices of the trail.
Located (Trail v)
, of course, has an instance which amounts to the
identity function. More generally, however, Located a
is an
instance of TrailLike
for any type a
which is also an
instance. In particular, the resulting Located a
has the location
of the input Located Trail
, and a value of type a
generated by
another call to trailLike
. This is most useful for generating
values of type Located (Trail' Line v)
and Located (Trail' Loop
v)
. For example, circle 3 # translateX 2 :: Located (Trail' Line
V2 Double)
is an open \(360^\circ\) circular arc centered at
\((2,0)\).
It is quite convenient to be able to use, say, square 2
as a
diagram, path, trail, list of vertices, etc., whichever suits one's
needs. Otherwise, either a long list of functions would be needed for
each primitive (like square
, squarePath
, squareTrail
,
squareVertices
, squareLine
, squareLocatedLine
, ... ugh!),
or else explicit conversion functions would have to be inserted when
you wanted something other than what the square
function gave you by
default.
As an (admittedly contrived) example, the following diagram defines
s
as an alias for square 2
and then uses it at four different
instances of TrailLike
:
> s = square 2 -- a squarish thingy.
>
> blueSquares = atPoints (concat . pathVertices $ s) {- 1 -}
> (replicate 4 (s {- 2 -} # scale 0.5) # fc blue)
> paths = lc purple . stroke $ star (StarSkip 2) s {- 3 -}
> aster = center . lc green . strokeLine
> . mconcat . take 5 . iterate (rotateBy (1/5))
> . onLineSegments init
> $ s {- 4 -}
> example = (blueSquares <> aster <> paths)
Exercise: figure out which occurrence of s
has which type. (Answers
below.)
At its best, this type-directed behavior results in a "it just
works/do what I mean" experience. However, it can occasionally be
confusing, and care is needed. The biggest gotcha occurs when
combining a number of shapes using (<>)
or mconcat
: diagrams,
paths, trails, and lists of vertices all have Monoid
instances, but
they are all different, so the combination of shapes has different
semantics depending on which type is inferred.
> ts = mconcat . iterateN 3 (rotateBy (1/9)) $ triangle 1
> example = (ts ||| strokeP ts ||| strokeLine ts ||| fromVertices ts) # fc red
The above example defines ts
by generating three equilateral
triangles offset by 1/9 rotations, then combining them with mconcat
.
The sneaky thing about this is that ts
can have the type of any
TrailLike
instance, and it has completely different meanings
depending on which type is chosen. The example uses ts
at each of
four different monoidal TrailLike
types:
Since example
is a diagram, the first ts
, used by itself, is
also a diagram; hence it is interpreted as three equilateral
triangle diagrams superimposed on one another with atop
.
strokeP
turns Path
s into diagrams, so the second ts
has type
Path V2 Double
. Hence it is interpreted as three closed triangular paths
superimposed into one three-component path, which is then stroked.
strokeLine
turns Trail' Line
s into diagrams, so the third
occurrence of ts
has type Trail' Line V2 Double
. It is thus
interpreted as three open triangular trails sequenced end-to-end
into one long open trail. As a line (i.e. an open trail), it is
not filled (in order to make it filled we could replace strokeLine
ts
with strokeLoop (glueLine ts)
).
The last occurrence of ts
is a list of points, namely, the
concatenation of the vertices of the three triangles. Turning this
into a diagram with fromVertices
generates a single-component,
open trail that visits each of the points in turn.
Of course, one way to avoid all this would be to give ts
a specific
type signature, if you know which type you would like it to be. Then
using it at a different type will result in a type error, rather than
confusing semantics.
Answers to the square 2
type inference challenge:
Path V2 Double
Diagram b V2 Double
[Point V2 n]
Trail' Line V2 Double
Segments and trails as parametric objects
Both segments and trails, semantically, can be seen as parametric
functions: that is, for each value of a parameter within some given
range (usually \([0,1]\)), there is a corresponding vector value
(or point, for Located
segments and trails). The entire collection
of such vectors or points makes up the segment or trail.
The Diagrams.Parametric
module provides tools for working with
segments and trails as parametric functions.
Parametric
As explained above, parametric objects can be viewed semantically as
functions. In particular, parametric objects of type p
can be seen
as functions of type Scalar (V p) -> Codomain p
, where the type
family Codomain
is defined in such a way as to make this true. For
example, Codomain (Trail V2 Double) ~ V2 Double
, because a trail can be thought of
as a function Double -> V2 Double
.
The Parametric
class defines the single method atParam
which
yields this parametric view of an object:
> atParam :: Parametric p => p -> Scalar (V p) -> Codomain p
(Note that it is not possible to convert in the other
direction—every function of type Scalar (V p) -> Codomain p
need
not correspond to something of type p
. For example, to convert from
a function to a trail one would need at the very least a guarantee of
continuity; segments are even more restricted.)
> spline :: Located (Trail V2 Double)
> spline = cubicSpline False [origin, 0 ^& 1, 1 ^& 1, 1 ^& 0] # scale 3
> pts = map (spline `atParam`) [0, 0.1 .. 1]
> spot = circle 0.2 # fc blue
>
> example = mconcat (map (place spot) pts) <> strokeLocTrail spline
Instances of Parametric
include:
Segment Closed
: The codomain is the type of vectors. Note there
is no instance for Segment Open
, since additional context is
needed to determine the endpoint, and hence the parametrization, of
an open segment.
FixedSegment
: The codomain is the type of points.
Trail'
: The codomain is the vector space. Note that there is no
difference between Line
and Loop
.
Trail
: same as the instance for Trail'
.
Located a
: as long as a
is also Parametric
and the codomain of
a
is a vector space, Located a
is parametric with points as the
codomain. For example, calling atParam
on a Located (Trail V2 Double)
returns a P2 Double
.
Path
s are not Parametric
, since they may have multiple trail
components and there is no canonical way to assign them a
parametrization.
DomainBounds
The domainLower
and domainUpper
functions simply return the lower
and upper bounds for the parameter. By default, these will be \(0\) and
\(1\), respectively. However, it is possible to have objects
parameterized over some interval other than \([0,1]\).
EndValues
The EndValues
class provides the functions atStart
and atEnd
,
which return the value at the start and end of the parameter interval,
respectively. In other words, semantically we have atStart x = x
`atParam` domainLower x
, but certain types may have more efficient
or accurate ways of computing their start and end values (for example,
Bézier segments explicitly store their endpoints, so there is no need
to evaluate the generic parametric form).
Sectionable
The Sectionable
class abstracts over parametric things which can be
split into multiple sections (for example, a trail can be split into
two trails laid end-to-end). It provides three methods:
splitAtParam :: p -> Scalar (V p) -> (p, p)
splits something of
type p
at the given parameter into two things of type p
.
The resulting values will be linearly reparameterized to cover the
same parameter space as the parent value. For example, a segment
with parameter values in \([0,1]\) will be split into two
shorter segments which are also parameterized over \([0,1]\).
section :: p -> Scalar (V p) -> Scalar (V p) -> p
extracts the
subpart of the original lying between the given parameters, linearly
reparameterized to the same domain as the original.
reverseDomain :: p -> p
reverses the parameterization. It
probably should not be in this class and is likely to move elsewhere
in future versions.
HasArcLength
HasArcLength
abstracts over parametric things with a notion of arc
length. It provides five methods:
arcLengthBounded
approximates the arc length of an object to
within a given tolerance, returning an interval which is guaranteed
to contain the true arc length.
arcLength
is similar to arcLengthBounded
, but returns a single
length value instead of an interval.
stdArcLength
approximates the arc length up to a standard
accuracy of \(\pm 10^{-6}\).
arcLengthToParam
converts an arc length to a parameter, up to a
given tolernace
stdArcLengthToParam
is like arcLengthToParam
, but using a
standard accuracy of \(\pm 10^{-6}\).
Adjusting length
Anything which is an instance of DomainBounds
, Sectionable
, and
HasArcLength
can be "adjusted" using the adjust
function, which
provides a number of options for changing the length and extent.
Computing tangents and normals
The Diagrams.Tangent
module contains functions for computing
tangent vectors and normal vectors to segments and trails, at an
arbitrary parametmer (tangentAtParam
, normalAtParam
) or at the
start or end (tangentAtStart
, tangentAtEnd
, normalAtStart
,
normalAtEnd
). (The start/end functions are provided because such
tangent and normal vectors may often be computed more quickly and
precisely than using the general formula with a parameter of 0 or 1.)
Splines
Constructing Bézier segments by hand is tedious. The
Diagrams.CubicSpline
module provides two functions for creating
smooth curves given a list of points.
The cubicSpline
function, given a list of points, constructs a
smooth curved path passing through each point in turn. The first
argument to cubicSpline
is a boolean value indicating whether the
path should be closed.
> pts = map p2 [(0,0), (2,3), (5,-2), (-4,1), (0,3)]
> spot = circle 0.2 # fc blue # lw none
> mkPath closed = position (zip pts (repeat spot))
> <> cubicSpline closed pts
> example = mkPath False ||| strutX 2 ||| mkPath True
For more precise control over the generation of curved paths, see the
Diagrams.TwoD.Path.Metafont
module from
diagrams-contrib
, which also has its own tutorial.
Diagrams.CubicSpline
also provides the bspline
function,
which creates a smooth curve (to be precise, a uniform cubic B-spline)
with the given points as control points. The curve begins and ends at
the first and last points, but in general does not pass through the
intermediate points.
> pts = map p2 (zip [0 .. 8] (cycle [0, 1]))
> example = mconcat
> [ bspline pts
> , mconcat $ map (place (circle 0.1 # fc blue # lw none)) pts
> ]
One major difference between cubicSpline
and bspline
is that the
curves generated by cubicSpline
depend on the control points in a
global way—that is, changing one control point could alter the
entire curve—whereas with bspline
, each control point only affects
a local portion of the curve.
Fill rules
There are two main algorithms or "rules" used when determining which
areas to fill with color when filling the interior of a path: the
winding rule and the even-odd rule. The rule used to draw a
path-based diagram can be set with fillRule
, defined in
Diagrams.TwoD.Path
. For simple, non-self-intersecting paths,
determining which points are inside is quite simple, and the two
algorithms give the same results. However, for self-intersecting
paths, they usually result in different regions being filled.
> loopyStar = fc red
> . mconcat . map (cubicSpline True)
> . pathVertices
> . star (StarSkip 3)
> $ regPoly 7 1
> example = loopyStar # fillRule EvenOdd
> ||| strutX 1
> ||| loopyStar # fillRule Winding
The even-odd rule specifies that a point is inside the path if a
straight line extended from the point off to infinity (in one
direction only) crosses the path an odd number of times. Points
with an even number of crossings are outside the path. This rule is
simple to implement and works perfectly well for
non-self-intersecting paths. For self-intersecting paths, however,
it results in a pattern of alternating filled and unfilled
regions, as seen in the above example. Sometimes this pattern is
desirable for its own sake.
The winding rule specifies that a point is inside the path if its
winding number is nonzero. The winding number measures how many
times the path "winds" around the point, and can be intuitively
computed as follows: imagine yourself standing at the given point,
facing some point on the path. You hold one end of an (infinitely
stretchy) rope; the other end of the rope is attached to a train
sitting at the point on the path at which you are looking. Now the
train begins traveling around the path. As it goes, you keep hold of
your end of the rope while standing fixed in place, not turning at
all. After the train has completed one circuit around the path,
look at the rope: if it is wrapped around you some number of times,
you are inside the path; if it is not wrapped around you, you are
outside the path. More generally, we say that the number of times
the rope is wrapped around you (positive for one direction and
negative for the other) is the point's winding number.
Draw a picture of you and the train
For example, if you stand outside a circle looking at a train
traveling around it, the rope will move from side to side as the
train goes around the circle, but ultimately will return to exactly
the state in which it started. If you are standing inside the
circle, however, the rope will end up wrapped around you once.
For paths with multiple components, the winding number is simply the
sum of the winding numbers for the individual components. This
means, for example, that "holes" can be created in shapes using a
path component traveling in the opposite direction from the outer
path.
This rule does a much better job with self-intersecting paths, and
it turns out to be (with some clever optimizations) not much more
difficult to implement or inefficient than the even-odd rule.
You should be aware that queries (see Using queries) use the
winding rule by default, and are not affected by the path fill rule
attribute. Thus, if you apply the even-odd rule to a diagram, the
query may not match in the way you expect. For this reason, if you
want to make a shape with holes in it, it is usually better to form
the holes from paths winding in the opposite direction (using
reversePath
and the winding rule) than from the even-odd rule. For
example, in the diagram below, the annulus on the left is formed using
the even-odd fill rule, and the one on the right with the default
winding rule and a reversed inner circle. The dark blue points
indicate places where the associated query evaluates to true.
> points = [x ^& 0 | x <- [-2.3, -2.1 .. 2.3]]
> dia1 = (circle 2 <> circle 1) # strokeP # fillRule EvenOdd # rotateBy (1/100)
> dia2 = (circle 2 <> reversePath (circle 1)) # strokeP # rotateBy (1/100)
>
> illustrate d = ((d # fc grey) `beneath`) . mconcat . map drawPt $ points
> where
> drawPt p | inquire d p = circle 0.1 # fc blue # moveTo p
> | otherwise = circle 0.07 # fc lightblue # moveTo p
>
> example = illustrate dia1 ||| strutX 1 ||| illustrate dia2
If you do want to make a diagram whose query uses the even-odd rule,
you can use the strokePath'
function.
Clipping
With backends that support clipping, paths can be used to clip other
diagrams. Only the portion of a clipped diagram falling inside the
clipping path will be drawn.
> example = square 3
> # fc green
> # lw veryThick
> # clipBy (square 3.2 # rotateBy (1/10))
Several functions are available, depending on what envelope and trace
you want the resulting diagram to have. clipBy
uses the envelope
and trace of the original diagram. clipped
uses the envelope and
trace of the clipping path. clipTo
uses the intersection of the two
envelopes, and a trace which matches the displayed outline of the
diagram. Note that in general the intersection of envelopes is larger
than the envelope of an intersection. Diagrams does not have a
function which returns the tight envelope of the intersection.
Altering a diagram's envelope can also be accomplished using withEnvelope
(see Envelope-related functions). The rectEnvelope
function is also
provided for the special case of setting a diagram's envelope to some
rectangle, often used for the purpose of selecting only a part of a
diagram to be "viewed" in the final output. It takes a point—the
lower-left corner of the viewing rectangle—and the vector from the
lower-left to upper-right corner.
> circles = (c ||| c) === (c ||| c) where c = circle 1 # fc fuchsia
> example = circles # center # rectEnvelope (p2 (-1,-1)) (r2 (1.3, 0.7))
Note in the above example how the actual portion of the diagram that
ends up being visible is larger than the specification given to
rectEnvelope
—this is because the aspect ratio of the requested
output image does not match the aspect ratio of the rectangle given to
rectEnvelope
(and also because of the use of frame
by the
framework which renders the user manual examples). If the aspect
ratios matched the viewed portion would be exactly that specified in
the call to rectEnvelope
.
Boolean operations on paths
The Diagrams.TwoD.Path.Boolean
module from
diagrams-contrib
contains functions for computing boolean
combinations of paths, such as union, intersection, difference, and
symmetric difference.
> import qualified Diagrams.TwoD.Path.Boolean as B
>
> thing1, thing2 :: Path V2 Double
> thing1 = square 1
> thing2 = circle 0.5 # translate (0.5 ^& (-0.5))
>
> example = hsep 0.5 . fc green . map strokePath $
> [ B.union Winding (thing1 <> thing2)
> , B.intersection Winding thing1 thing2
> , B.difference Winding thing1 thing2
> , B.exclusion Winding thing1 thing2
> ]
Trail and path implementation details
Trails are implemented using finger trees: in particular, lines are
finger trees of closed segments, while loops consist of a finger tree
of closed segments plus a single final open segment.
The benefit of using a finger tree (instead of just, say, a list, or
even a Seq
structure from Data.Sequence
) is that it allows
caching monoidal "measures" of the entire trail. In particular, the
finger trees underlying trails cache
For more details, see the Diagrams.Segment
and
Diagrams.Trail
modules.
Another interesting aspect of the implementation is that upon stroking
a path to form a diagram, instead of simply putting the entire path
into a primitive, we separate out the lines and loops into two path
primitives. This is helpful for backends because they often have to
do some active work to avoid filling lines, and if
diagrams-lib
did not do this separation, they would essentially
have to end up doing it themselves.
3.7 Text
Note: The various backends differ substantially in their
text-handling capabilities. For this and other reasons, there are
two ways to add text to diagrams, each with advantages. The
method in this section is heavily dependant on backend support.
The Cairo backend has the most complete support; in particular,
this is the best approach for complex (non-Roman) scripts. The
Rasterific backend also has good text support, via the
FontyFruity
package. You may also want to look at the
SVGFonts
package, described in the section Native font
support below, which converts text directly into Path
s.
Text objects, defined in Diagrams.TwoD.Text
, can be created
most simply with the text
function, which turns a String
into a
diagram with (centered) text:
> example = text "Hello world!" <> rect 8 1
Text with different alignments can be created using topLeftText
or
baselineText
(or, more generally, alignedText
, though it is not
supported by all backends—the SVG backend in particular only
supports an approximation to alignedText
):
> pt = circle 0.1 # fc red
>
> t1 = pt <> topLeftText "top left" <> rect 8 1
> t2 = pt <> baselineText "baseline" <> rect 8 1
> t3 = pt <> alignedText 0.7 0.5 "(0.7, 0.5)" <> rect 8 1
>
> d1 =/= d2 = d1 === strutY 2 === d2
> example = t1 =/= t2 =/= t3
The most important thing to keep in mind when working with text
objects is that they take up no space: they have a point envelope
at the origin, i.e. for the purposes of things like beside
, they
have a width and height of zero. (Note, however, this is not the same
as having an empty envelope. In particular, they still behave in an
intuitive manner when included as arguments to things like hcat
.)
If we omitted the rectangle from the above example, there would be no
output. Except: the PGF backend has the ability to create enveloped text
as does the Rasterific backend by using the Diagrams.Backend.Rasterifc.Text
module.
Text objects take up no space!
The main reason for this is that computing the size of some text in a
given font is rather complicated, and diagrams
cannot (yet) do it
natively.
Text is colored with the current fill color (see Color and
Opacity). Various other attributes of text can be set using font
,
fontWeight
, and fontSlant
. The convenience function italic
and
oblique
are provided for setting the font slant, and for weight
there are functions bold
, bolder
, lighter
, thinWeight
,
ultraLight
, light
, mediumWeight
, heavy
, semiBold
, and
ultraBold
. Note that many backends do not support font weights
besides bold
; the SVG backend supports all font weights.
> text' s t = text t # fontSize (local s) <> strutY (s * 1.3)
> example = center $
> text' 10 "Hello" # italic
> === text' 5 "there" # bold # font "freeserif"
> === text' 3 "world" # fc green
Font size
Font size is set using the fontSize
function, and is specified by a
value of type Measure V2 Double
(see Measurement units).
Text with a local
font size is measured relative to its local
vector space. Such text is transformed normally by any
transformations applied to it. For example, in the diagram below,
fontSize (local 1)
is specified (this is actually the default, so
it could be omitted without changing the diagram). Note how the F's
are the same size as a unit box, and scale, stretch, and rotate
along with it.
> eff = (text "F" <> square 1) # fontSize (local 1)
>
> example = hcat
> [eff, eff # scale 2, eff # scaleX 2, eff # scaleY 2, eff # rotateBy (1/12)]
Text whose font size is specified in any measurement other than
local
(that is, normalized
, global
, or output
) behaves
differently.
> eff = (text "F" <> square 1) # fontSize (normalized 0.1)
>
> example = hcat
> [eff, eff # scale 2, eff # scaleX 2, eff # scaleY 2, eff # rotateBy (1/12)]
There are several things to notice about the above example
diagram, which is identical to the previous one except for the
fact that normalized 0.1
is used instead of local 1
:
The F's are 1/10th the size of the overall diagram. If
we added more copies of eff
to the right, but kept the
physical size of the rendered image the same, the F's would
remain the same physical size on the screen, but would get
bigger relative to the boxes (since the boxes would be smaller
in absolute terms).
The F's are all about the same size—in particular, the uniform
scaling applied to the second F has no effect, and the fourth F is
not twice as tall as the others. Note, however, that the final F
rotates with the square as expected. Note also that the third and
fourth F's are squished, as one would expect from a non-uniform
scaling. The hand-wavy slogan is that non-local
-sized text is
"affected by transformations, but without changing size".
The technical specification is that applying a transformation
\(T\) to non-local
-sized text actually results in applying
the transformation \(\frac{1}{|T|} T\), where \(|T|\) denotes the
average scaling factor of the transformation \(T\), computed
as the square root of the positive determinant of \(T\). This
behaves nicely: for example, the average scaling factor of scale
k
is k
, so applying a uniform scaling to non-local
-sized text
has no effect; it is also compositional, so applying t
and then
s
to some text has exactly the same effect as applying s <> t
.
For more information, see the avgScale
function and the comments
associated with its source code.
Native font support
The SVGFonts package implements native text support for diagrams,
using fonts in the SVG format (note that it can be used with any
backend, not just the SVG backend). Among other things, it provides
its own textSVG
function which can be used to convert text into a
path tracing the outline of the text. Here is a simple example:
> text' d s = (strokeP $ SF.textSVG' (SF.TextOpts SF.lin2 SF.INSIDE_H SF.KERN False d d) s)
> # lw none
>
> example = text' 5 "Hello" # fc blue ||| text' 3 "world" # fc green
For more details and examples, see the Haddock documentation.
3.8 Images
The Diagrams.TwoD.Image
module provides basic support for
including both external and embedded images in diagrams.
Support for images varies by backend. Only the cairo
backend supports external images. The rasterific backend
supports embedded images of many formats and the SVG backend
supports embedded PNG images.
To create an embedded diagram from an image file call loadImageEmb
to read the image from a file path using JuicyPixels
and return
a DImage Embedded
. Then use image
to convert the DImage Embedded
to a diagram. You can also create an a diagram with an embedded image
by supplying a function that maps pixel coordinates to AlphaColour
s
plus a width and a height to the rasterDia
function. For example,
the below code uses rasterDia
to visualize the multiplication table
for the group \(U_7\) of natural numbers \(\{0, \dots, 6\}\)
under multiplication mod 7.
> import Data.Colour.Palette.BrewerSet
>
> no = (circle 1 <> hrule 2 # rotateBy (1/8))
> # lwO 40 # lc red # frame 0.2
> noPhoneIO = do
> res <- loadImageEmb "doc/static/phone.png"
> return $ case res of
> Left err -> mempty
> Right phone -> no <> image phone # sized (dims2D 1.5 1.5)
>
> colors = brewerSet YlGn 7
> u7 = rasterDia
> (\x y -> opaque (colors !! ((x `div` 100) * (y `div` 100) `mod` 7)))
> 700 700
> # sized (dims2D 2 2)
>
> example = do
> noPhone <- noPhoneIO
> return $ noPhone ||| strutX 1.5 ||| u7
The function loadImageExt
checks to make sure the file exists, uses
JuicyPixels
to determine its size and returns a reference to
the image. On the other hand uncheckedImageRef
simply packages the
reference with a width and height to make a DImage External
, without
checking to make sure the image exists.
When using loadImageEmb
and loadImageExt
you do not need to
provide the width and height of the image, as they will be calculated
by JuicyPixels
. Otherwise you must specify both a width and
a height for each image. In this case you might hope to be able to
specify just a width or just a height, and have the other dimension
computed so as to preserve the image's aspect ratio. However, there
is no way for diagrams
to query an image's aspect ratio until
rendering time, but (until such time as a constraint solver is added)
it needs to know the size of the image when composing it with other
subdiagrams. Hence, both dimensions must be specified, and for the
purposes of positioning relative to other diagrams, the image will
be assumed to occupy a rectangle of the given dimensions.
However, note that the image's aspect ratio will be preserved: if you
specify dimensions that do not match the actual aspect ratio of the
image, blank space will be left in one of the two dimensions to
compensate. If you wish to alter an image's aspect ratio, you can do
so by scaling nonuniformly with scaleX
, scaleY
, or something
similar.
Current backend support for images can be summarized as follows:
Cairo: external PNG
SVG: embedded PNG
; also PNG
and JPG
via a "native"
extension (see below)
Rasterific: embedded PNG
, JPG
, TIF
, BMP
and GIF
.
PGF: external PDF
, JPG
and PNG
; embedded ImageRGB8
.
Besides Embedded
and External
images, there is a third Native
type which supports image formats particular to a specific backend.
For more information, see "Diagrams.TwoD.Image":mod: as well as the
documentation for individual backends.