Trend Lines and Reference Zones

This tutorial provides an intuitive understanding of how any kind of trend / reference lines / zone can be created. Having the power of layers provides you with all the necessary tools required to create reference zones and trend lines.

You can read about composing layers to know what it is capable of doing.

A Single Reference Line

Let's create a single reference line on a bar chart, in our case, the average mileage throughout all the years. Scroll down to see the end result we are gonna achieve.

In order to get a reference line, we need to create a tick layer for drawing the line and a text layer to draw any kind of text to annotate the line.

  • First we have to calculate average value of all data points to show the average line.

    Canvas supports transform method which allows creation of additional data required for rendering from the root data using operators. A visualization can have multiple layers. Layers can have same or different data source. All the layers take instance of DataModel as source of data.

    The reference line layer shows the average value of Horsepower across the years, hence it needs an instance of DataModel which holds the average value. Here we are gonna transform the root DataModel instance to retrieve an instance of DataModel which holds the average value and give it a name so that we can refer it some other place.

    canvas
        .transform({
            calculatedAverage: dt => dt.groupBy([''], { Miles_per_Gallon: 'avg' })
        })
    
  • We'll start by creating a defining a composition of the reference line. Look at the final chart and think what is the line composed of. Obviously, it renders a line and text. So lets define the reference line

     layerFactory.composeLayers('referenceLine', [ averageLine, averageText ])
    

    layerFactory takes the definition of custom layers. It accepts a name for the newly created composite layer and definition in terms of atomic or another custom layer.

    We haven't yet defined what averageLine and averageText are supposed to be. So our next step is to define each of them

  • We'll define individual layers:

    • First we'll use a tick layer which gives us average line. The average value is already calculated in step 1, we just need to pass the name of the DataModel containing average value to layers. We use source property to pass the data source for a layer.
    const averageLine = {
        name: 'averageLine',
        mark: 'tick',
        source: 'calculatedAverage',
        className: 'average-line',
        encoding: {
            y: 'referenceLine.encoding.y',
            x: null
        }
    }
    
    • Then we'll describe the text layer which will give us the display the average in text format.
    const averageText = {
        name: 'averageText',
        mark: 'text',
        source: 'calculatedAverage',
        className: 'averageText',
        encoding: {
            y: 'referenceLine.encoding.y',
            text: 'referenceLine.encoding.text'
        }
    }
    
    • Now that we created everything we need, we'll now use this composite layer along with the simple bar layer in the canvas
    canvas
        .layers([
            { mark: 'bar' },
            {
                mark: 'referenceLine',
                encoding: {
                    text: { field: 'Horsepower' }
            }
        }
    ])
    

The text encoding is Miles Per Gallon to display the average mileage.

main
run-button
run-button
reset-button
// DataModel instance is created from https://www.charts.com/static/cars.json data,
// https://www.charts.com/static/cars-schema.json schema and assigned to variable rootDM.
 
let layerFactory = muze.layerFactory

layerFactory.composeLayers('referenceLine' /* name of composite layer */, [
    {
        name: 'averageLine', // Name of the line layer only
        mark: 'tick', // Defines what kind of plot to be used
        source: 'calulatedAverage', // Defines datasource from which it gets the data
        className: 'average-line', // CSS class name which the layer appends on group
        encoding: {
            y: 'referenceLine.encoding.y',
            x: null,
            color: { value: () => '#414141' }
        },
        calculateDomain: false // Dont calculate domain of axis from this layer
    },
    {
        name: 'averageText',
        mark: 'text',
        source: 'calulatedAverage',
        className: 'average-text',
        encoding: {
            y: 'referenceLine.encoding.y',
            text: 'referenceLine.encoding.text',
            color: { value: () => '#414141' },
        },
        encodingTransform: (points, layer, dependencies) => { /* Use this to change text position */
            let width = layer.measurement().width;
            let smartLabel = dependencies.smartLabel;
            for (let i = 0; i < points.length; i++) {
                let size = smartLabel.getOriSize(points[i].text);
                points[i].update.x = width - 5;
                points[i].textanchor = 'end';
                points[i].update.y -= size.height / 2;
            }
            return points;
        },
        calculateDomain: false /* No calculation of domain from this layer */
    }
]);
// Create an environment for future rendering
let env = muze();
// Create an instance of canvas which houses the visualization
let canvas = env.canvas();

canvas
    .rows(['Horsepower'])
    .columns(['Year'])
    .data(rootData)
    .transform({ calulatedAverage: (dt) => dt.groupBy([''], { Miles_per_Gallon: 'avg' }) })
    .layers([
        { mark: 'bar' }, 
        {
            mark: 'referenceLine',
            encoding: {
                text: {
                    field: 'Horsepower',
                    formatter: value => `Average Horsepower: ${Math.round(value)}`
                }
            }
        }
    ])
    .width(600)
    .height(400)
    .title('A Reference Line')
    .mount('#chart-container');

Implicit encoding and resolving encoding for composed layer

In the above sample the definition of averageLineor averageText is explained in the code with comments

{
    name: 'averageLine', // Name of the line layer only
    mark: 'tick', // Defines what kind of plot to be used
    source: 'calulatedAverage', // Defines datasource from which it gets the data
    className: 'average-line', // CSS class name which the layer appends on group
    encoding: {
        y: 'referenceLine.encoding.y',
        x: null,
        color: { value: () => '#414141' }
    },
    calculateDomain: false // Dont calculate domain of axis from this layer
},

But it feels like, there is something weird going on with the encoding object. We have seen encoding object before in layers method

canvas.layers([{
    mark: 'bar',
    encoding: {
        y: 'Horsepower',
        x: 'Year',
        color: { value: () => '#000000' }
    }
}])

Here using encoding we determine how y value (height of a bar) and x value (x position of a bar) are calculated for a point. Like, in the above example, we are essentially telling muze to calculate the height of a bar from the value of Horsepower field in DataModel instance. Similarly, we are assigning a static color to the plot using color property.

Lets see how layers for the reference line example looks like

.layers([
    { mark: 'bar' }, 
    {
        mark: 'referenceLine',
        encoding: {
            text: {
                field: 'Horsepower',
                formatter: value => `Average Horsepower: ${Math.round(value)}`
            }
        }
    }
])

Here we have not mentioned x, y in encoding but it still works. This is because Muze internally populates x and y encoding from rows and columns, if you don't provide it from outside. Muze internally translates the config to

.layers([
    { 
        mark: 'bar',
        encoding: {
            y: 'Horsepower',
            x: 'Year'
        }
    }, 
    {
        mark: 'referenceLine',
        encoding: {
            text: {
                y: 'Horsepower',
                x: 'Year',
                field: 'Horsepower',
                formatter: value => `Average Horsepower: ${Math.round(value)}`
            }
        }
    }
])

Now have a look at the newly composed averageLine layer's encoding

encoding: {
    y: 'referenceLine.encoding.y',
    x: null,
    color: { value: () => '#414141' }
},

Here for a composed layer we dont have the field's name from DataModel instance as y encoding value, it has some weird dot notation. Composed layers wont get access to DataModel's instance field directly. It has to be accessed through a path referenceLine.encoding.y. Refer the following image to understand the reference mechanism.

alt text

The left hand side code block is for composing layers where as the right hand side's code block is for layer definition. Check the color mapping in the image to understand how we are referring members from one section to another.

For example when referenceLine.encoding.text for referenceLine from the left block is getting resolved, it gets the value from { ... , encoding: { text: ... }} for layer which has referenceLine mark.

Encoding transform

Once layer finish computing value for a encoding , we might need to change the value for various reasons, like change x, y to eliminate overlapping, change color to improve readability etc...

In all those cases you can use encodingTransform to further transform the encoding of points. Here in the example, encodingTransform pushes the text towards right margin and leaves a padding between line and text.

You can remove the property from configuration and see what happens if you don't provide encodingTransform.

Reusablility

If you notice the code to draw average line example carefully, you will realize that definition of custom layers are completely independent from the rest of the system. This is an immensely powerful feature with versatile use case.

You might have complex plot definitions, annotations, markers saved as separate file as store, serve it to different visualization system and have it attached to any visualization you want.

Wrapping up

By now you probably have some understanding how powerful Muze's layers are. We will discuss layers in more details here. In the next tutorial we will see how can we customize tooltip.