├── .gitignore
├── .npmignore
├── LICENSE.md
├── Nuget.Config
├── README.md
├── compost.gif
├── docs
├── api.html
├── bundle.js
├── demos.html
├── index.html
├── lib
│ ├── docs.js
│ └── style.css
├── paper.pdf
├── releases
│ ├── compost-0.0.2.js
│ ├── compost-0.0.3.js
│ ├── compost-0.0.4.js
│ ├── compost-0.0.5.js
│ └── compost-latest.js
├── tutorial.html
└── usage.html
├── package-lock.json
├── package.json
├── public
└── index.html
└── src
├── compost
├── compost.fs
├── compost.fsproj
├── core.fs
└── html.fs
├── project
├── data.js
├── demos.js
└── standalone.js
├── webpack.dev.js
├── webpack.latest.js
└── webpack.pub.js
/.gitignore:
--------------------------------------------------------------------------------
1 | .ionide
2 | node_modules/
3 | src/compost/obj/
4 | src/compost/bin/
5 | docs/_site
6 | dist/
7 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .ionide
2 | docs
3 | public
4 | src
5 | compost.gif
6 | Nuget.config
7 | webpack.config.js
8 | node_modules
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2016, Tomas Petricek and contributors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/Nuget.Config:
--------------------------------------------------------------------------------
1 |
2 |
When specifying coordinates in Compost, you do so using a value from your domain 52 | rather than using a value in pixels. A value can be either categorical (such as 53 | a political party or a country) or a continuous (such as an exchange rate or a year).
54 |When specifying a location using a continuous value, you specify just a number.
55 | When using a categorical value, Compost associates a whole area to each category and
56 | so you need to give a category together with a number specifying a location within
57 | this area. The following are valid ways of specifying a Coord
:
// A continuous value such as exchange rate
60 | let v1 = 1.52
61 |
62 | // A continuous value such as a number of MPs
63 | let v2 = 152
64 |
65 | // Leftmost corner of an area associated with categorical value
66 | let v3 = ["Labour", 0]
67 |
68 | // Middle of an area associated with categorical value
69 | let v4 = ["Labour", 0.5]
70 |
71 | In other words, Coord
can be either a number or an array of two
72 | elements containing a string (the name of the category) and a number (between 0 and 1).
When you want to specify a location on a chart, you need an x and y coordinate.
74 | A Point
in the following documentation refers to an array of two
75 | Coord
elements, one for x and one for y coordinate. The following gives
76 | some examples of valid points:
// A pair of continuous values
79 | let p1 = [ 3.14, Math.sin(3.14) ]
80 |
81 | // A point with categorical X and continuous Y
82 | let p2 = [ ["Labour", 0.5], 152 ]
83 |
84 | // A list specifying a several points on a line
85 | let l1 = [ [0, Math.sin(0)], [1.57, Math.sin(1.57)],
86 | [3.14, Math.sin(3.14)], [4.71, Math.sin(4.71)] ]
87 |
88 | // A list specifying four corners of a rectangle
89 | let s1 = [ [["Labour", 0], 0], [["Labour", 0], 152],
90 | [["Labour", 1], 152], [["Labour", 1], 0] ]
91 |
92 |
93 | s.continuous | float * float -> Scale Creates a continuous scale that can contain value in the specified range |
---|---|
s.categorical | string[] -> Scale Creates a categorical scale that can contain categorical values specified in the given array of strings |
c.text | Coord * Coord * string * ?string * ?float -> Shape Draws a text specified as the third parameter at a given x and y coordinates specified by the first two parameters. The last two optional parameters specify alignment (baseline, hanging, middle, start, end, center) and rotation in radians. |
---|---|
c.bubble | Coord * Coord * float * float -> Shape Creates a bubble (point) at a specified x and y coordinates. The last two parameters specify the width and height of the bubble in pixels. |
c.shape | Point[] -> Shape Creates a filled shape. The shape is specified as an array of points (see the section on coordinates). |
c.line | Point[] -> Shape Creates a line drawn using the current stroke color. The line is specified as an array of points (see the section on coordinates) |
c.column | string * float -> Shape Creates a filled rectangle for use in a column chart. This is a shorthand for |
c.bar | float * string -> Shape Creates a filled rectangle for use in a bar chart. This is a shorthand for |
c.fillColor | string * Shape -> Shape Sets the fill color to be used for all shapes drawn using |
---|---|
c.strokeColor | string * Shape -> Shape Sets the line color to be used for all lines drawn using |
c.font | string * string * Shape -> Shape Sets the font and text color to be used for all text occurring in the given shape. |
c.nest | Coord * Coord * Coord * Coord * Shape -> Shape Creates a shape that occupies an explicitly specified space using the four coordinates as left and right X value and top and bottom Y values. Inside this explicitly specified space, the nested shape is drawn, using its own scales. |
---|---|
c.nestX | Coord * Coord * Shape -> Shape Same as above, but this primitive only overrides the X scale of the nested shape while the Y scale is left unchanged and can be shared with other shapes. |
c.nestY | Coord * Coord * Shape -> Shape Same as above, but this primitive only overrides the Y scale of the nested shape while the X scale is left unchanged and can be shared with other shapes. |
c.scale | Scale * Scale * Shape -> Shape Override the automatically inferred scale with an explicitly specified one. You can use this to define a custom minimal and maximal value. To create scales use |
c.scaleX | Scale * Shape -> Shape Override the automatically inferred X scale (as above). |
c.scaleY | Scale * Shape -> Shape Override the automatically inferred Y scale (as above). |
c.padding | float * float * float * float * Shape -> Shape Adds a padding around the given shape. The padding is specified as top, right, bottom, left. This will subtract the padding from the available space and draw the nested shape into the smaller space. |
c.overlay | Shape[] -> Shape Compose a given array of shapes by drawing them all in the same chart area. This calculates the scale of all nested shapes and those are then automatically aligned based on their coordinates. |
---|---|
c.axes | string * Shape -> Shape Draw axes around a given shape. The string parameter can be any string containing the words left, right, bottom and/or top, for example using space as a separator. |
c.render | string * Shape -> unit Render a given chart on a HTML element specified by a given ID. When called, Compost will get the width and height of the element and render a chart using this as the size. |
---|---|
c.on | Handlers * Shape -> Shape
137 | Specify event handlers for events that occur in the specified shape. The first parameter is a JavaScript object with fields
138 | representing individual handlers. The supported handlers are |
c.svg | float * float * Shape -> Html Render the given shape and build an object representing the chart as an HTML |
c.html | string * obj * (Html | string)[] -> Html Creates a HTML element that can be returned as a result of the rendering function in |
c.interactive | string * State * (State -> Event -> State) * ((Event -> unit) -> State -> (Html | Shape)) -> unit 145 | Create an interactive chart using a given HTML element ID. For an example of how this works, 146 | see the You draw it demo. This is based on the Elm architecture 147 | (also known Model-View-Update). The last three parameters specify the initial state, 148 | an update function (given a state and an event, produce a new state) and a view function 149 | (given a function to trigger an event and a current state, produce a shape). 150 | |
This page contains various examples of using Compost. The code is executed on the 40 | fly, so each of the code blocks is fully standalone. This also makes some examples 41 | longer, because we cannot reuse helper functions between files. All sample data 42 | is defined in the docs.js file on GitHub. 43 |
The following code sample shows how to construct a basic bar chart. 59 |
60 |let bars =
61 | c.axes("left bottom", c.scaleY(s.continuous(0, 410), c.overlay(
62 | elections.map(e =>
63 | c.padding(0, 10, 0, 10,
64 | c.fillColor(e.color, c.column(e.party, e.y17))
65 | ))
66 | )))
67 |
68 | c.render("out1a", bars)
69 |
70 |
71 |
72 | The following chart shows two bars for each political party. The x coordinates
74 | of a bar is given as ["Green", 0]
and ["Green", 0.5]
75 | for the first bar and ["Green", 0.5]
and ["Green", 1]
76 | for the second bar.
77 |
// Makes a color given in "#rrggbb" format darker or lighter
79 | // (by multiplying each component by the specified number k)
80 | function adjust(color, k) {
81 | let r = parseInt(color.substr(1, 2), 16)
82 | let g = parseInt(color.substr(3, 2), 16)
83 | let b = parseInt(color.substr(5, 2), 16)
84 | let f = n => n*k > 255 ? 255 : n*k;
85 | return "#" + ((f(r) << 16) + (f(g) << 8) + (f(b) << 0)).toString(16);
86 | }
87 |
88 | // Creates a bar of height 'y' that is witin a categorical value 'x'
89 | // starting at the offset 'f' and ending at the offset 't'.
90 | function partColumn(f, t, x, y) {
91 | return c.shape([ [ [x,f], y ], [ [x,t], y ], [ [x,t], 0 ], [ [x,f], 0 ] ])
92 | }
93 |
94 | let bars =
95 | c.axes("left bottom", c.scaleY(s.continuous(0, 410), c.overlay(
96 | elections.map(e =>
97 | c.padding(0, 10, 0, 10, c.overlay([
98 | c.fillColor(adjust(e.color, 0.8), partColumn(0, 0.5, e.party, e.y17)),
99 | c.fillColor(adjust(e.color, 1.2), partColumn(0.5, 1, e.party, e.y19))
100 | ]))
101 | )
102 | )))
103 |
104 | c.render("out1b", bars)
105 |
106 |
107 |
108 | The following chart shows two bars for each political party. The x coordinates
110 | of a bar is given as ["Green", 0]
and ["Green", 0.5]
111 | for the first bar and ["Green", 0.5]
and ["Green", 1]
112 | for the second bar. We also add a title using the c.nest
primitive
113 | to align the title and the chart itself.
114 |
// Makes a color given in "#rrggbb" format darker or lighter
116 | // (by multiplying each component by the specified number k)
117 | function adjust(color, k) {
118 | let r = parseInt(color.substr(1, 2), 16)
119 | let g = parseInt(color.substr(3, 2), 16)
120 | let b = parseInt(color.substr(5, 2), 16)
121 | let f = n => n*k > 255 ? 255 : n*k;
122 | return "#" + ((f(r) << 16) + (f(g) << 8) + (f(b) << 0)).toString(16);
123 | }
124 |
125 | // A derived Compost operation that adds a title to any given chart.
126 | // This works by creating text element and using 'nest' to allocate top
127 | // 15% of space for the title and the remaining 85% of space for the title.
128 | function title(text, chart) {
129 | let title = c.scale(s.continuous(0, 100), s.continuous(0, 100),
130 | c.font("11pt arial", "black", c.text(50, 80, text)))
131 | return c.overlay([
132 | c.nest(0, 100, 85, 100, title),
133 | c.nest(0, 100, 0, 85, chart)
134 | ])
135 | }
136 |
137 | // Creates a bar of height 'y' that is witin a categorical value 'x'
138 | // starting at the offset 'f' and ending at the offset 't'.
139 | function partColumn(f, t, x, y) {
140 | return c.shape([ [ [x,f], y ], [ [x,t], y ], [ [x,t], 0 ], [ [x,f], 0 ] ])
141 | }
142 |
143 | let bars =
144 | c.axes("left bottom", c.scaleY(s.continuous(0, 410), c.overlay(
145 | elections.map(e =>
146 | c.padding(0, 10, 0, 10, c.overlay([
147 | c.fillColor(adjust(e.color, 0.8), partColumn(0, 0.5, e.party, e.y17)),
148 | c.fillColor(adjust(e.color, 1.2), partColumn(0.5, 1, e.party, e.y19))
149 | ]))
150 | )
151 | )))
152 |
153 | c.render("out1c", title("United Kingdom general elections (2017 vs 2019)", bars))
154 |
155 |
156 |
157 | This demo creates a basic line chart showing GBP/USD exchange rate around 23 June 2016.
159 | This is done using c.line
which takes an array of x and y coordinates.
160 | We then change the line color, explicitly set the y scale (to make it look nicer)
161 | and add axes.
let rates = c.axes("left right bottom",
164 | c.scaleY(s.continuous(1.25, 1.52), c.strokeColor("#202020",
165 | c.line(gbpusd.map((v, i) => [i, v])))
166 | ))
167 | c.render("out2a", rates)
168 |
169 |
170 |
171 | This is the demo from the Compost home page. We create a line 173 | chart, but with two areas highlighted in different colors. This is achieved by overlaying 174 | two rectangular shapes and a line.
175 | 176 |let lo = 1.25, hi = 1.52
177 | let rates = c.axes("left right bottom", c.overlay([
178 | c.fillColor("#1F77B460", c.shape(
179 | [ [0,lo], [16,lo], [16,hi], [0,hi] ])),
180 | c.fillColor("#D6272860", c.shape(
181 | [ [gbpusd.length-1,lo], [16,lo], [16,hi], [gbpusd.length-1,hi] ])),
182 | c.strokeColor("#202020",
183 | c.line(gbpusd.map((v, i) => [i, v])))
184 | ]))
185 | c.render("out2b", rates)
186 |
187 |
188 |
189 | What if we wanted to highlight just the area under a line, rather than the whole
191 | background of the chart? The c.shape
primitive can fill any polygon,
192 | so we are not limited to specifying a rectangle as above. In the following, we generate
193 | the background shapes so that the bottom is flat, but the top is aligned with the
194 | data points on the line.
let lo = 1.25, hi = 1.52
197 | let rates = c.axes("left right bottom", c.overlay([
198 | c.fillColor("#1F77B460", c.shape(
199 | gbpusd.slice(0, 17).map((v, i) => [i, v])
200 | .concat([[16, lo], [0, lo]]) )),
201 | c.fillColor("#D6272860", c.shape(
202 | gbpusd.slice(16).map((v, i) => [i+16, v])
203 | .concat([[gbpusd.length-1, lo], [16, lo]]) )),
204 | c.strokeColor("#202020",
205 | c.line(gbpusd.map((v, i) => [i, v])))
206 | ]))
207 | c.render("out2c", rates)
208 |
209 |
210 |
211 | In this demo, we compare two different exchange rates. To do this, we create two
213 | line charts and then place one above the other. The charts will share the X axis, but
214 | they will have separate Y axes. This is done using the c.nestY
primitive,
215 | which nests a scale of a shape inside a newly defined locations. Here, the bottom chart
216 | takes are from 0 to 50 and the top chart takes an area from 50 to 100.
function body(lo, hi, data) {
219 | return c.axes("left right bottom", c.overlay([
220 | c.fillColor("#1F77B460", c.shape(
221 | [ [0,lo], [16,lo], [16,hi], [0,hi] ])),
222 | c.fillColor("#D6272860", c.shape(
223 | [ [data.length-1,lo], [16,lo], [16,hi], [data.length-1,hi] ])),
224 | c.strokeColor("#202020",
225 | c.line(data.map((v, i) => [i, v])))
226 | ]))
227 | }
228 | let rates = c.overlay([
229 | c.nestY(0, 50, body(1.25, 1.52, gbpusd)),
230 | c.nestY(50, 100, body(1.15, 1.32, gbpeur)),
231 | ])
232 |
233 | c.render("out2d", rates)
234 |
235 |
236 |
237 | This example shows how to create a scatterplot comparing two features of the 239 | data from the Iris dataset. This is done by overlaying bubbles. In addition, 240 | we use the Iris species to set the colors of the dots.
241 | 242 |let irisColors = { Setosa:"blue",
243 | Virginica:"green", Versicolor:"red" }
244 | let x = "sepal_width", y = "petal_width"
245 |
246 | let scatter =
247 | c.axes("left bottom", c.overlay(
248 | iris.map(i => c.strokeColor(
249 | irisColors[i.species], c.bubble(i[x], i[y], 1, 1)))
250 | ))
251 | c.render("out3a", scatter)
252 |
253 |
254 |
255 | Compost does not have a built-in histogram chart, but we can create this ourselves 257 | by writing a helper function that takes an array of data, splits it into bins of equal 258 | size and then counts the number of data points in each bin. The results can then be 259 | easily rendered by overlaying rectangular shapes.
260 | 261 |// Calculate bins of a histogram. The function splits the data into 10
262 | // equally sized bins, counts the values in each bin and returns an array
263 | // of three-element arrays with start of the bin, end of the bin and count
264 | function bins(data) {
265 | let lo = Math.min(...data), hi = Math.max(...data);
266 | let bins = {}
267 | for(var i=0; i<data.length; i++) {
268 | let k = Math.round((data[i]-lo)/(hi-lo)*9);
269 | if (bins[k]==undefined) bins[k]=1; else bins[k]++;
270 | }
271 | let keys = Object.keys(bins).map(k => k*1).sort()
272 | return keys.map(k =>
273 | [ lo + (hi - lo) * (k / 10),
274 | lo + (hi - lo) * ((k + 1) / 10), bins[k]]);
275 | }
276 |
277 | let hist =
278 | c.axes("left bottom", c.overlay(
279 | bins(iris.map(i => i["petal_width"])).map(b =>
280 | c.fillColor("#808080", c.shape(
281 | [ [b[0], b[2]], [b[1], b[2]], [b[1], 0], [b[0], 0] ])) )
282 | ))
283 | c.render("out3b", hist)
284 |
285 |
286 |
287 | This example combines the previous two into a single chart. It is a reimplementation 289 | of the seaborn library pairplot 290 | chart. We build a matrix of chart showing a pairwise scatter-plot for all features and 291 | a histogram of feature distributions at the diagonal.
292 | 293 |// Calculate bins of a histogram. The function splits the data into 10
294 | // equally sized bins, counts the values in each bin and returns an array
295 | // of three-element arrays with start of the bin, end of the bin and count
296 | function bins(data) {
297 | let lo = Math.min(...data), hi = Math.max(...data);
298 | let bins = {}
299 | for(var i=0; i<data.length; i++) {
300 | let k = Math.round((data[i]-lo)/(hi-lo)*9);
301 | if (bins[k]==undefined) bins[k]=1; else bins[k]++;
302 | }
303 | let keys = Object.keys(bins).map(k => k*1).sort()
304 | return keys.map(k =>
305 | [ lo + (hi - lo) * (k / 10),
306 | lo + (hi - lo) * ((k + 1) / 10), bins[k]]);
307 | }
308 |
309 | let irisColors = {Setosa:"blue", Virginica:"green", Versicolor:"red" }
310 | let cats = ["sepal_width", "petal_length", "petal_width"]
311 |
312 | let pairplot =
313 | c.overlay(cats.map(x => cats.reverse().map(y =>
314 | c.nest([x, 0], [x, 1], [y, 0], [y, 1],
315 | c.axes("left bottom", c.overlay(
316 | x == y
317 | ? bins(iris.map(i => i[x])).map(b =>
318 | c.fillColor("#808080", c.shape(
319 | [ [b[0], b[2]], [b[1], b[2]], [b[1], 0], [b[0], 0] ])) )
320 | : iris.map(i => c.strokeColor(irisColors[i.species],
321 | c.bubble(i[x], i[y], 1, 1)))
322 | ))))).flat())
323 |
324 | c.render("out3c", pairplot)
325 |
326 |
327 |
328 | This demo shows how to create interactive charts using Compost. It is inspired by the 330 | You draw it 331 | visualizations by New York Times. You can click on the chart to adjust the size of the bars. 332 | The demo is based on the Elm architecture and the main nice thing is that events are 333 | reported in domain units. 334 |
335 | 336 |let partyColors = {}
337 | for(var i = 0; i < elections.length; i++)
338 | partyColors[elections[i].party] = elections[i].color;
339 |
340 | function update(state, evt) {
341 | switch (evt.kind) {
342 | case 'set':
343 | if (!state.enabled) return state;
344 | let newValues = state.values.map(kv =>
345 | kv[0] == evt.party ? [kv[0], evt.newValue] : kv)
346 | return { ...state, values: newValues }
347 | case 'enable':
348 | return { ...state, enabled: evt.enabled }
349 | }
350 | }
351 |
352 | function render(trigger, state) {
353 | return c.axes("left bottom", c.scaleY(s.continuous(0, 400),
354 | c.on({
355 | mousedown: () => trigger({ kind:'enable', enabled:true }),
356 | mouseup: () => trigger({ kind:'enable', enabled:false }),
357 | mousemove: (x, y) => trigger({ kind:'set', party:x[0], newValue:y })
358 | }, c.overlay(state.values.map(kv =>
359 | c.fillColor(partyColors[kv[0]],
360 | c.padding(0, 10, 0, 10, c.column(kv[0], kv[1]))) ))
361 | )))
362 | }
363 |
364 | let init = { enabled:false, values: elections.map(e => [e.party, e.y19]) }
365 | c.interactive("out4", init, update, render)
366 |
367 |
368 |
369 | Compost is a data visualization library that lets you compose rich interactive 46 | data visualizations from a small number of basic primitives. The library is 47 | based on the functional programming idea of composable domain-specific languages. 48 | Compost is simple (implemented in just 700 lines of code) and easy to understand. 49 | Compost is a plain JavaScript library. You use it by writing JavaScript code that 50 | generates a chart using some 15 basic Compost primitives. 51 |
52 | 53 | 58 | 59 |The following example is a line chart showing the GBP/USD exchange rate 61 | around 23 June 2016. 62 | An interesting feature is that the chart highlights a part before the date using one 63 | color and the part after the date using another color.
64 | 65 |In Compost, we create the chart by composing three chart elements using
66 | c.overlay
. The three chart elements are two rectangles constructed
67 | using c.shape
and one line constructed using c.line
.
68 | Results of those functions are chart elements that can be further transformed
69 | using functions such as c.fillColor
and c.strokeColor
70 | that change the color of the element.
// Exchange rate range for the background
72 | let lo = 1.25, hi = 1.52;
73 |
74 | // Overlay three shapes and add axes on three sides
75 | let xchg = c.axes("left right bottom", c.overlay([
76 | // Fill area behind first 16 values in blue
77 | c.fillColor("#1F77B460", c.shape(
78 | [ [0,lo], [16,lo], [16,hi], [0,hi] ])),
79 | // Fill area behind the remaining values in red
80 | c.fillColor("#D6272860", c.shape(
81 | [ [gbpusd.length-1,lo], [16,lo], [16,hi], [gbpusd.length-1,hi] ])),
82 | // Draw a black line using 'gbpusd' array
83 | c.strokeColor("#202020", c.line(gbpusd.map((v, i) => [i, v])))
84 | ]));
85 |
86 | // Render chart on <div id="demo" />
87 | c.render("demo", xchg)
88 |
89 |
90 | The example illustrates two key ideas of Compost:
91 |40 | In this tutorial, we're going to progressively create a chart using Compost. We 41 | will start with the most basic one and gradually add a number of interesting features. 42 | This covers most of the core primitives that Compost.js provides for constructing charts. 43 | There are two things to keep in mind when reading the tutorial. 44 |
45 |46 | First, the primitives are quite basic on their own. This is precisely the 47 | point of the library. You have only a small number of basic primitives, but you can 48 | easily compose them to build interesting charts. 49 |
50 |
51 | Second, the primitives are just JavaScript functions that you call from your own
52 | JavaScript code. Most of the code snippets will look more like declarative specifications
53 | than like code, but that's just because we are building only basic charts with hard-coded
54 | data. In a real case, you would generate elements for example using the
55 | data.map(...)
function or using a for
loop. Crucially, this
56 | also means that you can wrap a part of the logic into a reusable function and define
57 | your own high-level operations.
58 |
60 | The final chart that we'll construct in the tutorial shows 61 | Boris 62 | Johnson's approval rating as of June 2020: 63 |
64 | 101 | 102 | 103 |Just for fun, let's start with a snippet that defines a single column using
105 | c.column
and then renders it using the c.render
function.
106 | Our column has a categorical X value "Positive"
and a continuous
107 | Y value 39
. The result is not very impressive:
let demo = c.column("Positive", 39);
109 | c.render("out1a", demo)
110 |
111 |
112 | What has happened here? Compost first infers the scales of the chart, which represent
113 | the range of values that may appear on its axes. For the X axis, the scale is categorical
114 | containing only one value "Positive"
. The scale for the Y axis is continuous
115 | and contains values form 0 (the bottom of the column) to 39 (the top of the column).
116 | Compost then projects the single column onto the available space and the single column
117 | 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
119 | c.overlay
, which takes a list of chart elements (shapes) and automatically
120 | aligns them according to the inferred scales:
let demo =
122 | c.overlay([
123 | c.column("Positive", 39),
124 | c.column("Negative", 43),
125 | c.column("Neutral", 17)
126 | ])
127 |
128 | c.render("out1b", demo)
129 |
130 |
131 | This is a bit better! We can see that the X axis has been divided into three 132 | equally-sized parts. Compost inferred that the X scale now contains three different 133 | (categorical) values. When rendering a column, it then maps the values onto the 134 | inferred scale and so each column takes only a part of the space. 135 | 136 |
Compost does not (currently) automatically assign colors to different columns.
138 | They are just shapes that are drawn using a specified color. To change the
139 | specified color for a part of a chart, we can use c.fillColor
, which
140 | changes color for a shape given as the second parameter. For lines, there is
141 | c.strokeColor
.
142 |
let demo =
144 | c.overlay([
145 | c.fillColor("#2CA02C", c.column("Positive", 39)),
146 | c.fillColor("#D62728", c.column("Negative", 43)),
147 | c.fillColor("#1F77B4", c.column("Neutral", 17))
148 | ])
149 |
150 | c.render("out2a", demo)
151 |
152 |
153 |
154 | Now we can see that the chart, indeed, consists of three separate columns!
155 | But how can we find out which one is which? To make this clear, we need to add
156 | axes. This is done using c.axes
, which is a primitive that adds axes
157 | around a specified (part) of a chart. The first parameter is a string that can contain
158 | any combination of left/right/top/bottom, depending on what axes we want:
159 |
let demo =
161 | c.axes("left bottom", c.overlay([
162 | c.fillColor("#2CA02C", c.column("Positive", 39)),
163 | c.fillColor("#D62728", c.column("Negative", 43)),
164 | c.fillColor("#1F77B4", c.column("Neutral", 17))
165 | ]))
166 |
167 | c.render("out2b", demo)
168 |
169 |
170 | An important thing to note is that we did not need to tell c.axes
what
171 | the ranges on the axes are. This is inferred automatically. As discussed before,
172 | Compost infers the scales for both axes (so that it can automatically align shapes)
173 | and the c.axes
primitive just accesses this information.
So far, our only primitive shape was a column created using c.column
.
177 | This is cheating slightly, because c.column
is not actually a primitive;
178 | it is just a very convenient helper. Under the cover, it uses the c.shape
179 | primitive, which takes a list of locations and fills a polygon specified by those
180 | locations.
A location consists of an X and Y coordinate and we specify them as 182 | two-element JavaScript arrays. If you have coordinates X1, Y1, X2, Y2, X3 and Y3 183 | and want to fill a triangle specified by those three points, you would specify a list 184 | of locations as: 185 |
186 |[ [x1, y1], [x2, y2], [x3, y3] ]
187 | How do we specify a single X or Y coordinate? There are two options, depending on
188 | whether the scale that we are using is continuous or categorical. For continous scales,
189 | the coordinate is just a numerical value. If the Y axis has a continous scale representing
190 | the approval ratings, then Y1 would be just 0
or 39
.
191 |
For categorical scales, specifying a location is trickier. A categorical value such as
193 | "Positive"
refers to a range rather than a single location, so if we want
194 | to specify a location, we need to add a numerical value between 0 and 1 that specifies an
195 | offset within the range. For example, ["Positive", 0]
is on the very left of
196 | 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,
198 | we need to give it four locations (representing the four corners of the rectangle), each
199 | with a categorical X value and a continuous Y value:
let demo =
202 | c.axes("left bottom", c.overlay([
203 | c.fillColor("#2CA02C", c.shape([
204 | [["Positive", 0], 0], [["Positive", 1], 0],
205 | [["Positive", 1], 39], [["Positive", 0], 39] ])),
206 | c.fillColor("#D62728", c.shape([
207 | [["Negative", 0], 0], [["Negative", 1], 0],
208 | [["Negative", 1], 43], [["Negative", 0], 43] ]))
209 | ]))
210 |
211 | c.render("out3a", demo)
212 |
213 |
214 |
215 | One nice consequence of how Compost works is that there is no need to distinguish between
216 | a column chart and a bar chart. Compost actually provides c.column
and
217 | c.bar
for convenience, but if we want to turn the previous column chart
218 | into a bar chart, we just switch the X and Y coordinates in the two lists:
219 |
let demo =
221 | c.axes("left bottom", c.overlay([
222 | c.fillColor("#2CA02C", c.shape([
223 | [0, ["Positive", 0]], [0, ["Positive", 1]],
224 | [39, ["Positive", 1]], [39, ["Positive", 0]] ])),
225 | c.fillColor("#D62728", c.shape([
226 | [0, ["Negative", 0]], [0, ["Negative", 1]],
227 | [43, ["Negative", 1]], [43, ["Negative", 0]] ]))
228 | ]))
229 |
230 | c.render("out3b", demo)
231 |
232 |
233 | This produces a bar chart as we wanted. It is also worth noting that the axes 234 | "just work", because they display values according to the inferred scales. Those 235 | are inferred from the X or Y values that appear in the individual shapes.
236 | 237 |Aside from filled polygons, Compost also provides primitives for drawing lines 239 | and adding text. Lines are obviously useful if you want to create a line chart, 240 | but there is nothing preventing you from combining multiple different shapes in 241 | a single chart.
242 |You might not be used to think about charts in this way, but there is a number 243 | of nice use cases. For example, what if we wanted to add an error bar to our bar 244 | chart? This is just a line in the middle of the bar.
245 |A line is specified in the same way as a shape, i.e. as a list of locations that 246 | consist of X and Y coordinates. Let's say that we want to display a bar chart with 247 | values 39+/-7 and 43+/-5 (those are made up numbers, not actual standard deviation 248 | of the approval ratings):
249 |let demo =
250 | c.axes("left bottom", c.overlay([
251 | c.fillColor("#2CA02C", c.shape([
252 | [["Positive", 0], 0], [["Positive", 1], 0],
253 | [["Positive", 1], 39], [["Positive", 0], 39] ])),
254 | c.strokeColor("black", c.line([
255 | [["Positive", 0.5], 32], [["Positive", 0.5], 46] ])),
256 | c.fillColor("#D62728", c.shape([
257 | [["Negative", 0], 0], [["Negative", 1], 0],
258 | [["Negative", 1], 43], [["Negative", 0], 43] ])),
259 | c.strokeColor("black", c.line([
260 | [["Negative", 0.5], 38], [["Negative", 0.5], 48] ])),
261 | ]))
262 |
263 | c.render("out4a", demo)
264 |
265 |
266 | This works fine, but it is not exactly great. The lines would look nicer if 267 | they also had smaller horizontal lines at the bottom and at the top. The code is also 268 | becoming quite long and would get much longer with more than two columns.
269 |Wouldn't it be nicer if you just had a built-in primitive for error bars?
270 | The nice thing about Compost is that this is pretty much exactly what we can do
271 | on our own! Just like there is c.column
helper, we can define our own
272 | helper errorLine
for creating error bars. This is just a JavaScript
273 | function that takes the X and Y values together with a standard deviation for the bar:
function errorLine(x, y, sdv) {
275 | return c.strokeColor("black", c.overlay([
276 | c.line([[[x, 0.5], y-sdv], [[x, 0.5], y+sdv]]),
277 | c.line([[[x, 0.45], y-sdv], [[x, 0.55], y-sdv]]),
278 | c.line([[[x, 0.45], y+sdv], [[x, 0.55], y+sdv]])
279 | ]));
280 | }
281 |
282 | let demo =
283 | c.axes("left bottom", c.overlay([
284 | c.fillColor("#2CA02C", c.column("Positive", 39)),
285 | errorLine("Positive", 39, 7),
286 | c.fillColor("#D62728", c.column("Negative", 43)),
287 | errorLine("Negative", 43, 5),
288 | c.fillColor("#1F77B4", c.column("Neutral", 17)),
289 | errorLine("Neutral", 17, 2)
290 | ]))
291 |
292 | c.render("out4b", demo)
293 |
294 |
295 | Once we define the errorLine
function, the rest of the code to create the
296 | chart is very succinct and clear. Of course, we had to define the helper function, but
297 | this is something that you only need to do once.
In all the previous examples, the scales were inferred automatically. In some cases,
301 | it is useful to be able to override the inferred scale and specify your own. For example,
302 | let's say that we want the range displayed on the Y axis to be from 0 to 100. We can
303 | do this using the c.scaleY
operation. The first argument of this operation
304 | is a scale, which can be created using the s.continuous
function:
let demo =
306 | c.axes("left bottom", c.scaleY(s.continuous(0, 100), c.overlay([
307 | c.fillColor("#2CA02C", c.column("Positive", 39)),
308 | c.fillColor("#D62728", c.column("Negative", 43)),
309 | c.fillColor("#1F77B4", c.column("Neutral", 17))
310 | ])))
311 |
312 | c.render("out5", demo)
313 |
314 |
315 | Here, we create a continuous scale using s.continuous
, which takes the
316 | smallest and the largest values as arguments. You can also explicitly create categorical
317 | scales using s.categorical
. In this case, you need to provide an array of
318 | strings representing the individual categories. This may be useful if you want to add
319 | a category for which you don't have data, or if you want to explicitly specify their order.
So far, we've seen how to combine multiple shapes using c.overlay
. This
323 | is, in fact, the only way to combine multiple shapes. However, there is much more we can
324 | do using this primitive if we combine it with a "nesting" operation that lets us
325 | 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
328 | chart. There is no built-in primitive for legends in Compost, but we can create one
329 | just by overlaying three bars (shapes) of the same size and three labels created using
330 | c.text
:
let demo =
332 | c.overlay([
333 | c.fillColor("#2CA02C", c.padding(10,0,10,0, c.bar(10, "Positive"))),
334 | c.fillColor("#D62728", c.padding(10,0,10,0, c.bar(10, "Negative"))),
335 | c.fillColor("#1F77B4", c.padding(10,0,10,0, c.bar(10, "Neutral"))),
336 | c.font("11pt arial", "black", c.overlay([
337 | c.text(12, ["Positive",0.5], "Positive", "start"),
338 | c.text(12, ["Negative",0.5], "Negative", "start"),
339 | c.text(12, ["Neutral",0.5], "Neutral", "start")
340 | ]))
341 | ])
342 |
343 | c.render("out6a", demo)
344 |
345 |
346 | There are three new primitives in this snippet. First, we use c.padding
347 | to add a space around the bar. This takes a padding that should be added from the
348 | top, right, bottom and the left side (the order is the same as in CSS). Second,
349 | we use c.text
to add a label. This takes the X and Y coordinates,
350 | followed by the label and a location with respect to the x, Y location. You can use
351 | baseline, hanging or middle for vertical alignment and start, end or center for
352 | horizontal alignment. Finally, we also specify font and text color using c.font
.
353 |
354 |
If you imagine the above chart as being much smaller, then it looks like a chart
355 | legend. There is one last thing that we need to do, which is to put everything we've
356 | done together. The following snippet (re)defines the errorLine
helper,
357 | defines a legend
shape from the previous code sample and creates a chart
358 | chart
. It then combines legend
with chart
into
359 | a single chart that is rendered. We discuss how this is done below:
function errorLine(x, y, sdv) {
361 | return c.strokeColor("black", c.overlay([
362 | c.line([[[x, 0.5], y-sdv], [[x, 0.5], y+sdv]]),
363 | c.line([[[x, 0.45], y-sdv], [[x, 0.55], y-sdv]]),
364 | c.line([[[x, 0.45], y+sdv], [[x, 0.55], y+sdv]])
365 | ]));
366 | }
367 |
368 | let legend =
369 | c.overlay([
370 | c.fillColor("#2CA02C", c.padding(7,0,7,0, c.bar(10, "Positive"))),
371 | c.fillColor("#D62728", c.padding(7,0,7,0, c.bar(10, "Negative"))),
372 | c.fillColor("#1F77B4", c.padding(7,0,7,0, c.bar(10, "Neutral"))),
373 | c.font("11pt arial", "black", c.overlay([
374 | c.text(12, ["Positive",0.5], "Positive", "start"),
375 | c.text(12, ["Negative",0.5], "Negative", "start"),
376 | c.text(12, ["Neutral",0.5], "Neutral", "start")
377 | ]))
378 | ])
379 |
380 | let chart =
381 | c.axes("left bottom", c.scaleY(s.continuous(0, 55), c.overlay([
382 | c.fillColor("#2CA02C", c.column("Positive", 39)),
383 | errorLine("Positive", 39, 7),
384 | c.fillColor("#D62728", c.column("Negative", 43)),
385 | errorLine("Negative", 43, 5),
386 | c.fillColor("#1F77B4", c.column("Neutral", 17)),
387 | errorLine("Neutral", 17, 2)
388 | ])))
389 |
390 | let demo = c.overlay([
391 | c.nest(0, 85, 0, 100, chart),
392 | c.nest(90, 100, 50, 100, legend)
393 | ])
394 | c.render("out6b", demo)
395 |
396 |
397 | In order to put the legend on the right side of the chart, we use the
398 | c.nest
operation and then overlay the results. The nest operation
399 | takes two locations (X1, X2, Y1, Y2) and draws the shape it gets as the last argument
400 | inside a rectangle specified by those two points. The scales inside the shape can be
401 | completely different from the scales on the outside.
In our example, both shapes that we
403 | combine are constructed using c.nest
and the scale on the outside is
404 | thus defined only by the locations given to c.nest
.
405 | The X and Y values for the rectangle containing the main chart are 0 to 85 (for X)
406 | and 0 to 100 (for Y). For the legend, those are 90 to 100 (for X) and 0 to 50 (for Y).
407 | This means that we're dedicating 85% of the horizontal space for the main chart,
408 | followed by 5% gap and 10% for the legend. Vertically, the legend only occupies the top
409 | half of the space. Note that our choice of numbers from 0 to 100 is arbitrary. This is
410 | just an (automatically inferred) continuous scale, so the code would work equally well
411 | with values between 0 and 1.
To summarize, the tutorial shows the two key features of Compost:
415 |errorLine
to draw an error bar
428 | for a bar chart. Similarly, we could define one to add a legend or a title
429 | or to create a particular chart such as pairplot. If you want to experiment with Compost, the easiest option is to add a reference
42 | to the latest version of Compost using a standalone JavaScript file from GitHub.
43 | This defines global variables c
(for creating Compost charts)
44 | and s
(for defining scales) that you can use in your scripts.
45 | The following complete HTML file creates a basic line chart:
<!DOCTYPE html>
47 | <html>
48 | <head>
49 | <script src="https://compostjs.github.io/compost/releases/compost-latest.js">
50 | </script>
51 | </head>
52 | <body>
53 | <div id="demo" style="width:600px;height:300px"></div>
54 | <script type="text/javascript">
55 | let d = c.axes("left bottom",
56 | c.line([[1,1],[2,4],[3,9],[4,16],[5,25],[6,36] ]))
57 | c.render("demo", d)
58 | </script>
59 | </body>
60 | </html>
61 |
62 | For production use, you should download the file
63 | and add it to your project rather than referencing it directly from GitHub. You can
64 | get a specific version by replacing latest
with, for example, 0.0.2
.
65 |
66 |
If you want to reference Compost.js properly, you can get it from the NPM repository 68 | by installing the compostjs package. 69 | Just run the following command:
70 |npm install compostjs
71 |
72 | Then you can use Compost in your project by importing the two exported
73 | global variables compost
(for access to Compost primitives)
74 | and scale
(for creating scales) as follows:
import { scale as s, compost as c } from "compostjs"
77 |
78 | let d = c.axes("left bottom",
79 | c.line([[1,1],[2,4],[3,9],[4,16],[5,25],[6,36] ]))
80 | c.render("demo", d)
81 |
82 |
83 | To see how this works, you can also look at the complete Node.js sample project 84 | compost-node-demos 85 | on GitHub. To clone the repository and run the demos, you can run the following 86 | commands. Once you do this, you'll see the running demos at 87 | http://localhost:8080. 88 | 89 |
git clone https://github.com/compostjs/compost-node-demos.git
90 | cd compost-node-demos
91 | npm install
92 | npm start
93 |
94 |