Introduction
I like using Brent Yorgey’s diagrams1 package to create images by writing Haskell2. Here’s a small example:
and the code to generate it:
hello :: Diagram B
hello = vsep 5
. zipWith letterRow [ "HELLO", "WORLD!" ]
$ L.tails cols
where letterRow ls = centerX . hsep 5 . zipWith letterDisc ls
letterDisc l c = letter l <> circle 10 # lw 2.5 # fc c
cols = cycle [ red, green, blue ]
letter c = stroke (textSVG [c] 20)
# fc yellow # lw 1.0 # lc yellow
In the example above graphics primitives circle
, stroke
, and
textSVG
are combined to make the final image. The combinators
include hsep
and vsep
, which take lists of elements and stack them
separated by a space, and the <>
operator which puts one diagram on
top of another.
There are lots of little modifiers e.g. lw
which sets the line
width: these are just functions, but the #
operator lets us write
the object being styled before the styling.
This article covers a few points which struck me as being particularly interesting, or I wanted to think about more carefully. All the information is included in the fine, official documentation:
A principled package
Like Haskell the diagrams package has a strong theoretical underpinning. As an example, an important distinction is made between a location in space (a Point) and a displacement in space (a Vector). Although both can be represented by a coordinate tuple, they are very different animals:
It makes no sense to add a Point to a Point and get a Point, but it is perfectly natural to add a Vector to a Vector and get a Vector. You could also add a Vector to a Point and get another Point.
If you translate a Point it becomes a different Point; translating a Vector leaves it unchanged. If this seems odd it might help to think of a Vector as the displacement between two Points, both of which will move in the same way when translated.
You can’t turn a Vector into a Point unless you specify an Origin.
Some software conflates Points and Vectors, perhaps because they often have the same representation: the Haskell diagrams package doesn’t. If you think the distinction is worthwhile, then I think you’ll enjoy using diagrams; on the other hand, if you think it’s just pedantry I suspect you’ll be frustrated.
The official diagrams documentation has a helpful introduction to vectors and points6 which discusses all this in more detail.
Silly games with Vectors and Points
The basic type of a two-dimensional vector is V2 n
where n
tells us the underlying scalar type e.g. Double
. You can
make such a vector in lots of ways:
*Main> V2 1.0 2.0
V2 1.0 2.0
*Main> r2 (3.0, 4.0)
V2 3.0 4.0
*Main> (5.0 ^& 6.0) :: V2 Double
V2 5.0 6.0
You can make Points in similar ways, though note that there’s no
P2
constructor:
*Main> p2 (1.0, 2.0)
P (V2 1.0 2.0)
*Main> (3.0 ^& 4.0) :: P2 Double
P (V2 3.0 4.0)
Although it’s an internal detail, we make a Point by wrapping a Vector. For example we could have written the last example above as:
*Main> (3.0 ^& 4.0) :: Point V2 Double
P (V2 3.0 4.0)
Having created Vectors and Points, we can now transform them. Here we translate a Vector and a Point, noting that the former is unchanged:
*Main> translateX 10 $ r2 (0,1)
V2 0 1
*Main> translateX 10 $ p2 (0,1)
P (V2 10 1)
As you might expect, we can do all this in three-dimensions too e.g.:
> (0.0 ^& 1.0 ^& 2.0) :: V3 Double
V3 0.0 1.0 2.0
*Main> translateX 10 $ p3 (0,1,2)
P (V3 10 1 2)
Polymorphism
The astute reader will have noticed that we applied translateX
to
both Points and Vectors, in both two- and three-dimensions. Clearly it’s
a polymorphic function so let’s look at its type:
*Main> :t translateX
translateX
:: (Additive (V t), Num (N t), R1 (V t), Transformable t) =>
N t -> t -> t
This rather scary signature needs a bit of unpicking. Ignoring the stuff before the fat arrow, we have the type:
N t -> t -> t
Having seen how it’s used, we know that t
is something like a Point
or a Vector, and N t
is a scalar of the appropriate type. In the
examples above, we had e.g.:
t ~> V2 Double
N t ~> Double
So it’s clear that N
is a type level function which extracts the
underlying type from a more complicated thing. Looking now at the
constraints before the fat arrow, we also see V t
which is the
vector-space in which t
lives.
Most of the constraints on t
are straight-forward: it needs
to be transformable, the underlying type has to be numeric, and
so on. The most interesting term is R1 (V t)
which loosely
means that the vector-space in which t
lives has to have a
first dimension: R1
extracts that coordinate.
By contrast if we look at translateZ
,
*Mail> :t translateZ
translateZ
:: (Additive (V t), Num (N t), R3 (V t), Transformable t) =>
N t -> t -> t
the R1
constraint is now R3
which constrains the vector-space
to have a third-dimension. In practical terms this means that if we
try to translate a two-dimensional point in the Z-direction, it will
fail at compile time:
*Main> translateZ 10 $ p2 (0,1)
<interactive>:66:1: error:
• Could not deduce (R3 V2) arising from a use of ‘translateZ’
from the context: Num n
bound by the inferred type of it :: Num n => P2 n
at <interactive>:66:1-24
• In the expression: translateZ 10
In the expression: translateZ 10 $ p2 (0, 1)
In an equation for ‘it’: it = translateZ 10 $ p2 (0, 1)
The meaning of this might not be immediately obvious to the casual observer.
Type classes
It’s worth stating explicitly that many of the functions in the diagrams API don’t take a particular type: rather they take any type which conforms to the relevant type class constraints. This is elegant and powerful, but it can lead to unwieldy signatures and Byzantine error messages. The User Manual has some useful tips and tricks7 on this topic.
More positively, if we return to the Transformable
type class
above, we can find many
instances8.
Unsurprisingly you can apply translateX
to all sorts of things,
including diagrams and other transformations. It’s nice that one
function can move so many things.
As with the translation examples above, particular transformations may place other contraints on the objects which are being transformed. However any type will work if it has the necessary instances to satisfy the constraints.
The diagrams manual has a good Type class reference9 which explains all this and more.
Monoids
A general theme in Haskell is that abstract mathematical ideas are often translated into a Haskell type class. If you create something which obeys the laws of the type class, you can make an instance of the type class which both saves writing code and unifies syntax.
For example, a monoid10 is a structure with a single associative operation and an identity element. Essentially this means that we can take two things and combine them into another thing of the same type, and that there’s a particular element which doesn’t change things when you combine with it. If there isn’t such an identity element you formally have a semigroup, not a monoid, but I’ll gloss over that distinction here.
The Haskell type class corresponding to a monoid is Data.Monoid11, and a while ago I wrote some notes12 about it. Rather than rehashing that theory, let’s just look at some examples to illustrate the general idea.
You can make a list monoid where the operation is concatenation, and the identity element the empty list:
[1,2,3] <> [4,5,6] = [1,2,3,4,5,6]
[1,2,3] <> [] = [1,2,3]
[] <> [1,2,3] = [1,2,3]
It’s easy to see that this is associative:
([1,2] <> [3,4]) <> [5,6] = [1,2,3,4] <> [5,6]
= [1,2,3,4,5,6]
[1,2] <> ([3,4] <> [5,6]) = [1,2] <> [3,4,5,6]
= [1,2,3,4,5,6]
We could also make a monoid from the integers under addition with zero as the identity (or a different one under multiplication):
1 <> 2 = 3
1 <> 0 = 1
0 <> 1 = 1
(1 <> 2) <> 3 = 3 <> 3
= 6
1 <> (2 <> 3) = 1 <> 5
= 6
Although the meaning of the <>
operator changes, it obeys the same rules
in both cases.
Similarly, we can make a monoid for diagrams. Here, the operator means putting one diagram on top of the other:
I think it’s clear that the empty diagram is a perfectly good identity element here.
Turning back to the operator, the order matters, as it does with lists. Mathematically, we’d say that the operator isn’t commutative:
However, the operator is associative and that’s all that matters if you want to be a monoid:
More diagrammatic Monoids
Besides diagrams themselves, the diagram package has many other monoid instances.
For example, if you have two transformations you can either apply them sequentially, or combine them into one uber-transformation and then apply that. So we can make a monoid instance for transformations.
Other examples abound: the word ‘Monoid’ appears nearly fifty times
in the documentation for Diagrams.Core
13.
Making a #
of things
Diagrams makes extensive use of #
which is flipped function
application. fc red
is a function which makes the foreground-colour
of a diagram red. You might use it thus:
fc red (circle 2)
but it is more elegant to say:
circle 2 # fc red
It’s worth emphasizing that there’s nothing diagrams specific about
#
. You could also say things like:
> "Wibble" # length
6
The &
function in Data.Function
is similar, but has a lower
precedence (1 vs 8). If we used this instead, we would typically
need more parentheses, and we’re writing Haskell not Lisp.
Named diagrams
Diagrams (including subdiagrams) can be named, and then subsequently referred to by name. This is extremely helpful because it allows you to refer to some element of a diagram after it’s been composed.
For me it greatly extended the scope of the diagrams I could make without using explicit coordinates14. )
There are many ways to use names, but for simple cases I use
named
15
to give something a name:
circle 1 # fc green # named "Foo"
and withName
16
to operate on a named subdiagram:
addMark n = withName n $
atop . place (circle 0.1 # fc red) . location
Names don't have to be strings: you can use any instance of
isName
17.
Chess board example
To show how useful names can be, consider the example below which draws a chess board with named squares, then fills it with pieces.
data Square = Square Char Int
deriving (Eq, Ord, Show)
instance IsName Square
chessBoard :: PreparedFont Double -> Diagram B
chessBoard f = L.foldl' (flip draw) cbBoard piecePosns
where draw (n,s) = withName n $
atop . place (myText f 16 [s]) . location
piecePosns :: [(Square, Char)]
piecePosns = concatMap (uncurry doFile) [('a', "♖♘♗♕♔♗♘♖")
,('b', "♙♙♙♙♙♙♙♙")
,('g', "♟♟♟♟♟♟♟♟")
,('h', "♜♞♝♛♚♝♞♜")
]
where doFile file = zipWith (\r p -> (Square file r, p)) [1..8]
cbBoard :: Diagram B
cbBoard = vcat . reverse
. zipWith cbRank ['a' .. 'h' ]
$ L.tails bgs
where cbRank rank = hcat . zipWith (cbCell rank) [1..8]
cbCell rank file b = square 10.0
# fc b # lw 0.5 # lc black
# named (Square rank file)
bgs = cycle [darkgoldenrod,lightgoldenrodyellow]
myText :: PreparedFont Double -> Double -> String -> Diagram B
myText f h t = stroke (textSVG' opts t)
# fc black # lw 0
where opts = TextOpts f INSIDE_H KERN False h h
Font Acknowledgment
I should begin by saying that I’m using Alexander Lange’s fine Quivira font18 to draw all the pieces, which makes things much easier. If you want to use this:
download the font;
convert it into SVG format with FontForge19;
load the font with the SVGFonts package20.
Implementation
The key function is cbBoard
which draws an empty board by assembling
squares into ranks, then ranks into the board. The cells are all named
with their rank and file e.g. Square 'c' 7
. It is nice that you
can use almost anything sensible as a name with relatively little
effort.
Having generated the board, we just fold over a list of pieces and their locations, grab the cell by its name and draw the piece on it. At no stage do we have to worry about where the cell is: we just ask for it by name.
Conclusions
In many ways I think the diagrams package is a microcosm of Haskell itself: there’s quite a steep learning curve, because it embraces some clever and abstract ideas. However, once you’ve absorbed those ideas it’s a joy to use and affords new insights into the problem you’re trying to solve.
References
- 1. https://archives.haskell.org/projects.haskell.org/diagrams/
- 2. https://www.haskell.org
- 3. https://archives.haskell.org/projects.haskell.org/diagrams/doc/quickstart.html
- 4. https://archives.haskell.org/projects.haskell.org/diagrams/doc/manual.html
- 5. http://hackage.haskell.org/package/diagrams
- 6. https://diagrams.github.io/doc/vector.html
- 7. https://archives.haskell.org/projects.haskell.org/diagrams/doc/manual.html#tips-and-tricks
- 8. https://archives.haskell.org/projects.haskell.org/diagrams/haddock/diagrams-core/Diagrams-Core-Transform.html#g:4
- 9. https://diagrams.github.io/doc/manual.html#type-reference
- 10. https://en.wikipedia.org/wiki/Monoid
- 11. http://hackage.haskell.org/package/base-4.14.0.0/docs/Data-Monoid.html
- 12. ../../2015/04/monoid.html
- 13. https://hackage.haskell.org/package/diagrams-core-1.4.2/docs/Diagrams-Core.html
- 14. https://archives.haskell.org/projects.haskell.org/diagrams/doc/manual.html?ref#using-absolute-coordinates
- 15. http://hackage.haskell.org/package/diagrams-lib-1.4.3/docs/Diagrams-Names.html#v:named
- 16. http://hackage.haskell.org/package/diagrams-lib-1.4.3/docs/Diagrams-Names.html#v:withName
- 17. http://hackage.haskell.org/package/diagrams-lib-1.4.3/docs/Diagrams-Names.html#t:IsName``
- 18. http://www.quivira-font.com
- 19. https://fontforge.org/en-US/
- 20. https://hackage.haskell.org/package/SVGFonts