Getting started with Compost

In this tutorial, we're going to progressively create a chart using Compost. We will start with the most basic one and gradually add a number of interesting features. This covers most of the core primitives that Compost.js provides for constructing charts. There are two things to keep in mind when reading the tutorial.

First, the primitives are quite basic on their own. This is precisely the point of the library. You have only a small number of basic primitives, but you can easily compose them to build interesting charts.

Second, the primitives are just JavaScript functions that you call from your own JavaScript code. Most of the code snippets will look more like declarative specifications than like code, but that's just because we are building only basic charts with hard-coded data. In a real case, you would generate elements for example using the data.map(...) function or using a for loop. Crucially, this also means that you can wrap a part of the logic into a reusable function and define your own high-level operations.

The final chart that we'll construct in the tutorial shows Boris Johnson's approval rating as of June 2020:

Creating and aligning columns

Just for fun, let's start with a snippet that defines a single column using c.column and then renders it using the c.render function. Our column has a categorical X value "Positive" and a continuous Y value 39. The result is not very impressive:

let demo = c.column("Positive", 39);
c.render("out1a", demo)

What has happened here? Compost first infers the scales of the chart, which represent the range of values that may appear on its axes. For the X axis, the scale is categorical containing only one value "Positive". The scale for the Y axis is continuous and contains values form 0 (the bottom of the column) to 39 (the top of the column). Compost then projects the single column onto the available space and the single column takes the entire available space. It is also drawn using a boring default gray color.

For the next step, we're going to combine multiple columns. To do this, we use c.overlay, which takes a list of chart elements (shapes) and automatically aligns them according to the inferred scales:

let demo =
  c.overlay([
    c.column("Positive", 39),
    c.column("Negative", 43),
    c.column("Neutral", 17)
  ])

c.render("out1b", demo)

This is a bit better! We can see that the X axis has been divided into three equally-sized parts. Compost inferred that the X scale now contains three different (categorical) values. When rendering a column, it then maps the values onto the inferred scale and so each column takes only a part of the space.

Adding colors and axes

Compost does not (currently) automatically assign colors to different columns. They are just shapes that are drawn using a specified color. To change the specified color for a part of a chart, we can use c.fillColor, which changes color for a shape given as the second parameter. For lines, there is c.strokeColor.

let demo =
  c.overlay([
    c.fillColor("#2CA02C", c.column("Positive", 39)),
    c.fillColor("#D62728", c.column("Negative", 43)),
    c.fillColor("#1F77B4", c.column("Neutral", 17))
  ])

c.render("out2a", demo)

Now we can see that the chart, indeed, consists of three separate columns! But how can we find out which one is which? To make this clear, we need to add axes. This is done using c.axes, which is a primitive that adds axes around a specified (part) of a chart. The first parameter is a string that can contain any combination of left/right/top/bottom, depending on what axes we want:

let demo =
  c.axes("left bottom", c.overlay([
    c.fillColor("#2CA02C", c.column("Positive", 39)),
    c.fillColor("#D62728", c.column("Negative", 43)),
    c.fillColor("#1F77B4", c.column("Neutral", 17))
  ]))

c.render("out2b", demo)

An important thing to note is that we did not need to tell c.axes what the ranges on the axes are. This is inferred automatically. As discussed before, Compost infers the scales for both axes (so that it can automatically align shapes) and the c.axes primitive just accesses this information.

Specifying coordinates

So far, our only primitive shape was a column created using c.column. This is cheating slightly, because c.column is not actually a primitive; it is just a very convenient helper. Under the cover, it uses the c.shape primitive, which takes a list of locations and fills a polygon specified by those locations.

A location consists of an X and Y coordinate and we specify them as two-element JavaScript arrays. If you have coordinates X1, Y1, X2, Y2, X3 and Y3 and want to fill a triangle specified by those three points, you would specify a list of locations as:

[ [x1, y1], [x2, y2], [x3, y3] ]

How do we specify a single X or Y coordinate? There are two options, depending on whether the scale that we are using is continuous or categorical. For continous scales, the coordinate is just a numerical value. If the Y axis has a continous scale representing the approval ratings, then Y1 would be just 0 or 39.

For categorical scales, specifying a location is trickier. A categorical value such as "Positive" refers to a range rather than a single location, so if we want to specify a location, we need to add a numerical value between 0 and 1 that specifies an offset within the range. For example, ["Positive", 0] is on the very left of the range and ["Positive", 1] is on the very right.

To use c.shape to recreate the same column chart as in the previous example, we need to give it four locations (representing the four corners of the rectangle), each with a categorical X value and a continuous Y value:

let demo =
  c.axes("left bottom", c.overlay([
    c.fillColor("#2CA02C", c.shape([
      [["Positive", 0], 0], [["Positive", 1], 0],
      [["Positive", 1], 39], [["Positive", 0], 39] ])),
    c.fillColor("#D62728", c.shape([
      [["Negative", 0], 0], [["Negative", 1], 0],
      [["Negative", 1], 43], [["Negative", 0], 43] ]))
  ]))

c.render("out3a", demo)

One nice consequence of how Compost works is that there is no need to distinguish between a column chart and a bar chart. Compost actually provides c.column and c.bar for convenience, but if we want to turn the previous column chart into a bar chart, we just switch the X and Y coordinates in the two lists:

let demo =
  c.axes("left bottom", c.overlay([
    c.fillColor("#2CA02C", c.shape([
      [0, ["Positive", 0]], [0, ["Positive", 1]],
      [39, ["Positive", 1]], [39, ["Positive", 0]] ])),
    c.fillColor("#D62728", c.shape([
      [0, ["Negative", 0]], [0, ["Negative", 1]],
      [43, ["Negative", 1]], [43, ["Negative", 0]] ]))
  ]))

c.render("out3b", demo)

This produces a bar chart as we wanted. It is also worth noting that the axes "just work", because they display values according to the inferred scales. Those are inferred from the X or Y values that appear in the individual shapes.

Combining different shapes

Aside from filled polygons, Compost also provides primitives for drawing lines and adding text. Lines are obviously useful if you want to create a line chart, but there is nothing preventing you from combining multiple different shapes in a single chart.

You might not be used to think about charts in this way, but there is a number of nice use cases. For example, what if we wanted to add an error bar to our bar chart? This is just a line in the middle of the bar.

A line is specified in the same way as a shape, i.e. as a list of locations that consist of X and Y coordinates. Let's say that we want to display a bar chart with values 39+/-7 and 43+/-5 (those are made up numbers, not actual standard deviation of the approval ratings):

let demo =
  c.axes("left bottom", c.overlay([
    c.fillColor("#2CA02C", c.shape([
      [["Positive", 0], 0], [["Positive", 1], 0],
      [["Positive", 1], 39], [["Positive", 0], 39] ])),
    c.strokeColor("black", c.line([
      [["Positive", 0.5], 32], [["Positive", 0.5], 46] ])),
    c.fillColor("#D62728", c.shape([
      [["Negative", 0], 0], [["Negative", 1], 0],
      [["Negative", 1], 43], [["Negative", 0], 43] ])),
    c.strokeColor("black", c.line([
      [["Negative", 0.5], 38], [["Negative", 0.5], 48] ])),
  ]))

c.render("out4a", demo)

This works fine, but it is not exactly great. The lines would look nicer if they also had smaller horizontal lines at the bottom and at the top. The code is also becoming quite long and would get much longer with more than two columns.

Wouldn't it be nicer if you just had a built-in primitive for error bars? The nice thing about Compost is that this is pretty much exactly what we can do on our own! Just like there is c.column helper, we can define our own helper errorLine for creating error bars. This is just a JavaScript function that takes the X and Y values together with a standard deviation for the bar:

function errorLine(x, y, sdv) {
  return c.strokeColor("black", c.overlay([
    c.line([[[x, 0.5], y-sdv], [[x, 0.5], y+sdv]]),
    c.line([[[x, 0.45], y-sdv], [[x, 0.55], y-sdv]]),
    c.line([[[x, 0.45], y+sdv], [[x, 0.55], y+sdv]])
  ]));
}

let demo =
  c.axes("left bottom", c.overlay([
    c.fillColor("#2CA02C", c.column("Positive", 39)),
    errorLine("Positive", 39, 7),
    c.fillColor("#D62728", c.column("Negative", 43)),
    errorLine("Negative", 43, 5),
    c.fillColor("#1F77B4", c.column("Neutral", 17)),
    errorLine("Neutral", 17, 2)
  ]))

c.render("out4b", demo)

Once we define the errorLine function, the rest of the code to create the chart is very succinct and clear. Of course, we had to define the helper function, but this is something that you only need to do once.

Overriding inferred scales

In all the previous examples, the scales were inferred automatically. In some cases, it is useful to be able to override the inferred scale and specify your own. For example, let's say that we want the range displayed on the Y axis to be from 0 to 100. We can do this using the c.scaleY operation. The first argument of this operation is a scale, which can be created using the s.continuous function:

let demo =
  c.axes("left bottom", c.scaleY(s.continuous(0, 100), c.overlay([
    c.fillColor("#2CA02C", c.column("Positive", 39)),
    c.fillColor("#D62728", c.column("Negative", 43)),
    c.fillColor("#1F77B4", c.column("Neutral", 17))
  ])))

c.render("out5", demo)

Here, we create a continuous scale using s.continuous, which takes the smallest and the largest values as arguments. You can also explicitly create categorical scales using s.categorical. In this case, you need to provide an array of strings representing the individual categories. This may be useful if you want to add a category for which you don't have data, or if you want to explicitly specify their order.

Combining different charts and more

So far, we've seen how to combine multiple shapes using c.overlay. This is, in fact, the only way to combine multiple shapes. However, there is much more we can do using this primitive if we combine it with a "nesting" operation that lets us create a new virtual space with its own scales within a larger chart.

To demonstrate nesting, we're going to add a legend to the right side of our bar chart. There is no built-in primitive for legends in Compost, but we can create one just by overlaying three bars (shapes) of the same size and three labels created using c.text:

let demo =
  c.overlay([
    c.fillColor("#2CA02C", c.padding(10,0,10,0, c.bar(10, "Positive"))),
    c.fillColor("#D62728", c.padding(10,0,10,0, c.bar(10, "Negative"))),
    c.fillColor("#1F77B4", c.padding(10,0,10,0, c.bar(10, "Neutral"))),
    c.font("11pt arial", "black", c.overlay([
      c.text(12, ["Positive",0.5], "Positive", "start"),
      c.text(12, ["Negative",0.5], "Negative", "start"),
      c.text(12, ["Neutral",0.5], "Neutral", "start")
    ]))
  ])

c.render("out6a", demo)

There are three new primitives in this snippet. First, we use c.padding to add a space around the bar. This takes a padding that should be added from the top, right, bottom and the left side (the order is the same as in CSS). Second, we use c.text to add a label. This takes the X and Y coordinates, followed by the label and a location with respect to the x, Y location. You can use baseline, hanging or middle for vertical alignment and start, end or center for horizontal alignment. Finally, we also specify font and text color using c.font.

If you imagine the above chart as being much smaller, then it looks like a chart legend. There is one last thing that we need to do, which is to put everything we've done together. The following snippet (re)defines the errorLine helper, defines a legend shape from the previous code sample and creates a chart chart. It then combines legend with chart into a single chart that is rendered. We discuss how this is done below:

function errorLine(x, y, sdv) {
  return c.strokeColor("black", c.overlay([
    c.line([[[x, 0.5], y-sdv], [[x, 0.5], y+sdv]]),
    c.line([[[x, 0.45], y-sdv], [[x, 0.55], y-sdv]]),
    c.line([[[x, 0.45], y+sdv], [[x, 0.55], y+sdv]])
  ]));
}

let legend =
  c.overlay([
    c.fillColor("#2CA02C", c.padding(7,0,7,0, c.bar(10, "Positive"))),
    c.fillColor("#D62728", c.padding(7,0,7,0, c.bar(10, "Negative"))),
    c.fillColor("#1F77B4", c.padding(7,0,7,0, c.bar(10, "Neutral"))),
    c.font("11pt arial", "black", c.overlay([
      c.text(12, ["Positive",0.5], "Positive", "start"),
      c.text(12, ["Negative",0.5], "Negative", "start"),
      c.text(12, ["Neutral",0.5], "Neutral", "start")
    ]))
  ])

let chart =
  c.axes("left bottom", c.scaleY(s.continuous(0, 55), c.overlay([
    c.fillColor("#2CA02C", c.column("Positive", 39)),
    errorLine("Positive", 39, 7),
    c.fillColor("#D62728", c.column("Negative", 43)),
    errorLine("Negative", 43, 5),
    c.fillColor("#1F77B4", c.column("Neutral", 17)),
    errorLine("Neutral", 17, 2)
  ])))

let demo = c.overlay([
  c.nest(0, 85, 0, 100, chart),
  c.nest(90, 100, 50, 100, legend)
])
c.render("out6b", demo)

In order to put the legend on the right side of the chart, we use the c.nest operation and then overlay the results. The nest operation takes two locations (X1, X2, Y1, Y2) and draws the shape it gets as the last argument inside a rectangle specified by those two points. The scales inside the shape can be completely different from the scales on the outside.

In our example, both shapes that we combine are constructed using c.nest and the scale on the outside is thus defined only by the locations given to c.nest. The X and Y values for the rectangle containing the main chart are 0 to 85 (for X) and 0 to 100 (for Y). For the legend, those are 90 to 100 (for X) and 0 to 50 (for Y). This means that we're dedicating 85% of the horizontal space for the main chart, followed by 5% gap and 10% for the legend. Vertically, the legend only occupies the top half of the space. Note that our choice of numbers from 0 to 100 is arbitrary. This is just an (automatically inferred) continuous scale, so the code would work equally well with values between 0 and 1.

Conclusions

To summarize, the tutorial shows the two key features of Compost:

Learn more about Compost