Compose is a declarative vector graphics system written in Julia. It's designed to simplify the creation of complex graphics and serves as the basis of the Gadfly data visualization package.
In a declarative graphics system, a figure is built without specifying the precise sequence of drawing commands but by arranging shapes and attaching properties. This makes it easy to break a complex graphic into manageable parts and then figure out how to combine the parts.
Graphics in Compose are defined using a tree structure. It's not unlike SVG in this regard, but has simpler semantics. There are three important types that make up the nodes of the tree:
Context: An internal node.
Form: A leaf node that defines some geometry, like a line or a polygon.
Property: A leaf node that modifies how its parent's subtree is drawn, like fill color, font family, or line width.
The all-important function in Compose, is called, not surprisingly,
compose(a, b) will return a new tree rooted at
a and with
b attached as a child.
That's enough to start drawing some simple shapes.
using Compose compose(compose(context(), rectangle()), fill("tomato"))
In the first example, we had to call
compose twice just to draw a lousy red square. Fortunately
compose has a few tricks up its sleeve. As everyone from lisp hackers and phylogeneticists knows, trees can be defined most tersely using S-expressions. We can rewrite our first example like:
# equivalent to compose(compose(context(), rectangle()), fill("tomato"))) compose(context(), rectangle(), fill("tomato"))
Furthermore, more complex trees can be formed by grouping subtrees with parenthesis or brackets.
compose(context(), (context(), circle(), fill("bisque")), (context(), rectangle(), fill("tomato")))
A useful function for visualizing the graphic that you've constructed is
introspect. It takes a
Context defining a graphic and returns a new graphic with a schematic of the tree.
tomato_bisque = compose(context(), (context(), circle(), fill("bisque")), (context(), rectangle(), fill("tomato"))) introspect(tomato_bisque)
This is a little cryptic, but you can use this limited edition decoder ring:
In addition to forming internal nodes to group
Property children, a
Context can define a coordinate system using the
context(x0, y0, width, height) form. Here we'll reposition some circles by composing them with contexts using different coordinate systems.
compose(context(), fill("tomato"), (context(0.0, 0.0, 0.5, 0.5), circle()), (context(0.5, 0.5, 0.5, 0.5), circle()))
The context's box (i.e.
(x0, y0, width, height)) is given in terms of its parent's coordinate system and defaults to
(0, 0, 1, 1). All the children of a context will use coordinates relative to that box.
This is an easy mechanism to translate the coordinates of a subtree in the graphic, but coordinates can be scaled and shifted as well by passing a
UnitBox to the
compose(context(), (context(units=UnitBox(0, 0, 1000, 1000)), polygon([(0, 1000), (500, 1000), (500, 0)]), fill("tomato")), (context(), polygon([(1, 1), (0.5, 1), (0.5, 0)]), fill("bisque")))
Complex visualizations often are defined using a combination of relative and absolute units. Compose makes these easy. In fact there are three sorts of units used in Compose:
(0w, 0h)is always the top-left corner of the contxt, and
(1w, 1h)is always the bottom-right. (Constants:
Any linear combination of these types of units is allowed. For example:
0.5w + 2cm - 5cx is a valid measure that can be used anywhere.
Often one needs to produce many copies of a similar shape. Most of the forms an properties have a scalar and vector forms to simplify this sort of mass production.
circle as an example, which has two constructors:
circle(x=0.5w, y=0.5h, r=0.5w) circle(xs::AbstractArray, ys::AbstractArray, rs::AbstractArray)
The first of these creates only circle centered at
(x, y) with radius
r. The second form can succinctly create many circles:
compose(context(), circle([0.25, 0.5, 0.75], [0.25, 0.5, 0.75], [0.1, 0.1, 0.1]), fill(LCHab(92, 10, 77)))
The arrays in passed to
rs need not be the same length. Shorter arrays will be cycled. This let's us shorten this last example by only specifying the radius just once.
compose(context(), circle([0.25, 0.5, 0.75], [0.25, 0.5, 0.75], [0.1]), fill(LCHab(92, 10, 77)))
fill is a property can also be vectorized here to quickly assign different colors to each circle.
compose(context(), circle([0.25, 0.5, 0.75], [0.25, 0.5, 0.75], [0.1]), fill([LCHab(92, 10, 77), LCHab(68, 74, 192), LCHab(78, 84, 29)]))
If vector properties are used with vector forms, they must be of equal length.
Though we've so far explained
compose as producing trees, there's nothing stopping one from producing an arbitrary directed graph. This can be quite useful in some cases.
In this example, only one triangle object is ever initialized, despite many triangles being drawn, which is possible because the graph produced by
siepinski is not a tree. The triangle polygon has many parent nodes than “re-contextualize” that triangle by repositioning it.
function sierpinski(n) if n == 0 compose(context(), polygon([(1,1), (0,1), (1/2, 0)])) else t = sierpinski(n - 1) compose(context(), (context(1/4, 0, 1/2, 1/2), t), (context( 0, 1/2, 1/2, 1/2), t), (context(1/2, 1/2, 1/2, 1/2), t)) end end compose(sierpinski(6), fill(LCHab(92, 10, 77)))
There are no safeguards to check for cycles. You can produce a graph with a cycle and Compose will run in an infinite loop trying to draw it. In most applications, this isn't a concern.