Composing Layers

So far, majority of available libraries available in the ecosystem force us to think a visualization as chart type (although there are beautiful exceptions). It's prefect to get started in a minutes. But this approach almost always leads to scalability issues very soon.

Imagine you had a simple bar chart and then you wanted labels on top of the bars , so you probably switch one configuration on from the input data. But what if you need the labels at the bottom of the chart, what if you want to show a bubble at the top of bars at some calculated distance. If the vendor library does not support it, you have to wait for them to write or you have to write the whole visualization on you own. And this is just tip of the iceberg problem.

Muze's atomic marks and composable layers allows to overcome all these situations. You can build almost any visualization you want using this concept. And this is just one of the many usecase.

Another powerful use case is, you can have any custom layers and compose it with any different layers. Imagine markers of your visulization, annotations are saved as separate files and on demand you attach those layers with existing bar / line / area layer to achieve same functionality everywhere.

Prerequisite

You need the knowledge of basic layer before reading this document. Go ahead and read basic layer first before continuing further.

Data

We will be using cars.json data for illustration.

Creating multiple layers with same data source

Remember we mentioned

layer = data + mark

The following example creates multiple layer with same data source

Global layers for all fields

Here we are just gonna instruct Muze to render a chart using two layers with mark line and point

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 dm.
// This is part view of the whole code. Click on copy icon to copy the whole code.
.data(dm)
.layers([
	{ mark: 'point' },
  	{ mark: 'line' }
])
.rows(['Displacement'])
.columns(['Year'])

As you can see, both the point and line layers are powered by same data source dm.

A layer with mark cannot be drawn without planer and retinal encodings. But here we have not mentioned any encoding in the layer. So how is it still working?

The reason is simple, if the user does not pass any information regarding encoding, Muze generates the encoding for a layer from fields assigned to rows, columns, color, shape and size.

Internally Muze generates another layer configuration which looks like

.layers([{ 
    mark: 'point',
    encoding: { 
        y: 'Displacement', /* taken from rows */
        x: 'Year' /* taken from columns*/,
        color: /* take first color from palette as no color encoding is given */,
        shape: /* take first size from list of shapes since no shape encoding is given */
        size: /* take a default size since no size encoding is given */
    }
}, {
    mark: 'line',
    encoding: { 
        y: 'Displacement', /* taken from columns */
        x: 'Year', /* taken from rows */
        color: /* take first color from palette as no color encoding is given */,
    }
}])

With this config both of the layers gets rendered.

If you try to make the chart dual axis, the layers adapt itself automatically for both fields.

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 dm.
// This is part view of the whole code. Click on copy icon to copy the whole code.
.data(dm)
.layers([
	{ mark: 'point' },
  	{ mark: 'line' }
])
.rows([['Displacement'], ['Acceleration']])
.columns(['Year'])

In the first example, only one field has been assigned to y-axis, hence one instance of each of those layers were created. In the above example, two fields are used to create two y-axes, hence 2 instances of both of line and point layers are created, one for each axis.

Again since we have not passed any encoding for layers, Muze populates encoding from fields assigned to rows, columns, color, shape and size. A generated layer config for above code would look like

.layers([{ 
    mark: 'point',
    encoding: { 
        y: 'Displacement', /* taken from rows */
        x: 'Year' /* taken from columns */,
        color: /* take first color from palette as no color encoding is given */,
        shape: /* take first size from list of shapes since no shape encoding is given */
        size: /* take a default size since no size encoding is given */
    }
}, {
    mark: 'line',
    encoding: { 
        y: 'Displacement', /* taken from columns */
        x: 'Year', /* taken from columns */
        color: /* take first color from palette as no color encoding is given */,
    }
},{ 
    mark: 'point',
    encoding: { 
        y: 'Acceleration', /* taken from rows */
        x: 'Year' /* taken from columns */,
        color: /* take first color from palette as no color encoding is given */,
        shape: /* take first size from list of shapes since no shape encoding is given */
        size: /* take a default size since no size encoding is given */
    }
}, {
    mark: 'line',
    encoding: { 
        y: 'Acceleration', /* taken from columns */
        x: 'Year', /* taken from columns */
        color: /* take first color from palette as no color encoding is given */,
    }
}])

Field specific layers

But what if, unlike the previous example, we want to plot a bar layer for Acceleration and point, line layer for Displacement?

By now you must be having some hunch that we just have to assign proper y encodings to the particular layers so that Muze does not generate its default layer configuration. And thats exactly what we have done

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 dm.
// This is part view of the whole code. Click on copy icon to copy the whole code.
.data(dm)
.layers([{
	mark: 'bar',
	encoding: { y: 'Acceleration' }
}, {
    mark: 'point',
    encoding: {
      	y: 'Displacement',
      	color: { 
          	value: () => '#414141' 
        }
    }
}, {
    mark: 'line',
    encoding: {
      	y: 'Displacement', 
      	color: {
          	value: () => '#414141'
        }
    }
}])
.rows([['Displacement'], ['Acceleration']])

All we did was mentioning the y encoding for a layer.

If you look at the code for above example, you will see that we have partially defined the encoding for the layers. Since we have assigned Acceleration to y encoding for bar layer, bar plot gets rendered for Acceleration vs Year chart.

logo

Visual mapping of layer to axis in dual axes

Dual axes charts are very difficult to read normally. One of the many overheads which dual axes brings in the plate is to determine which plot is attached to which axes.

In order to visually mapping layer to axis we have colored the name of axes with the color of the plot using css.

The code for that might not be visible to you in the code section, copy the code section and paste it to your preferred code editor to see the complete code.

A variation of the previous example is split canvas example with field specific layer. All the configuration remains same, its just field assigned to rows and columns drives visualization like this

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 dm.
// This is part view of the whole code. Click on copy icon to copy the whole code.
.data(dm)
.layers([{
	mark: 'bar',
	encoding: { y: 'Acceleration' }
}, {
    mark: 'point',
    encoding: { y: 'Displacement' }
}, {
    mark: 'line',
    encoding: { y: 'Displacement' }
}])
.rows(['Displacement', 'Acceleration'])
.columns(['Year'])

This is just a simple variation of the dual axes example to show how layers are independent of the rest of the layout system.

Notice we don't have the layer and axis mapping problem we were having earlier due to the dual axes. Hence, we have removed the color encoding from line and point layers.

Creating multiple layers with different data source

So far we have created layers which are powered by same data source. But there are fair number of use cases where two layer can have two different data source. Say, a visualization which shows change of weight of cars over the year and in the background you have a text showing the sample size of the experiment.

Here, if you notice carefully, you will realize that the are area layer is drawn from Weight_in_lbs and Year field, but the text layer is drawn from the a separate data source: number of records in the dataset.

Here we use transform property of Muze to transform the DataModel instance passed as input to calculate the total sample size. Once the model is calculated assign this calculated model as souce to text layer.

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 dm.
  
const calculateVariable = DataModel.Operators.calculateVariable;

const countFn = calculateVariable({
	name: 'Sample Size',
    type: 'measure',
    defAggFn: 'sum', // When ever aggregation happens, it counts the number of elements in the bin
    numberFormat: val => parseInt(val, 10)
}, ['Name', () => 1]);
  
const carCountDM = countFn(dm)

const env = muze();
const canvas = env.canvas();

canvas
    .data(carCountDM)
    .width(600)
    .height(400)
  	.transform({
		weightChangeModel: model => model.groupBy([''], {})
	})
    .layers([{
        mark: 'area',
        encoding: { y: 'Weight_in_lbs' },
        interpolate: 'catmullRom' /* spline */
      }, {
        mark: 'text',
        encoding: {
          	x: { field: null },
          	y: { field: null },
          	text: {
            	field: 'Sample Size',
            	formatter: (t) => 'Sample size: ' + t
          	},
        },
		className: 'summary-text',
        source: 'weightChangeModel',
        encodingTransform: (points, layerInst) => { /* Post drawing, position transformation of text */
        	const measurement = layerInst.measurement();
          	points[0].update.x = measurement.width / 2;
          	points[0].update.y = measurement.height / 1.2;
          	return points;
        },
        calculateDomain: false,
        interactive: false
	}])
    .rows(['Weight_in_lbs'])
    .columns(['Year'])
    .mount('#chart-container');

We create a new variable to calculate the total number of cars (sample size) in data, by doing

const countFn = calculateVariable({
    name: 'Sample Size',
    type: 'measure',
    defAggFn: 'sum', // When ever aggregation happens, it counts the number of elements in the bin
    numberFormat: val => parseInt(val, 10)
}, ['Name', () => 1]);
const carCountDM = countFn(dm)

All it does is create a new filed named Sample Size and intialize the cell with value 1.

Next, when transform data source is calculated from

.transform({
    weightChangeModel: model => model.groupBy([''], {})
})

Sample Size gets aggregated with aggregation function sum giving us the total count of cars. The newly created data source is a named data source. We can access this data source by using weightChangeModel identifier.

The last step is to tell the layer, which data source to use by using the source property. If you don't specify the name of data source then by default Muze assigned the input data source to layer.

There is another property you should know about is encodingTransform. Once data points in layer gets positioned based on planer encoding (x and y) and gets visual representation based on retinal encoding (color, shape, size), you might feel the need to change the calculated values of encoding of a point. For adjustments like this we use encodingTransform. Like, in the above example, we positioned the text at a particular position in the canvas (horizontally middle towards the bottom) using encodingTransfrom.

Composing a custom mark

We are now going to discuss, probably the most insane feature of a layers.

Let's take the example of first chart we build using layers in this lesson. It was a line layer with a point layer stacked along the z axis. Let's call it anchored line chart. If you are not sure of what are we talking about, scroll up until you see the first line chart.

We are just going to bring one little enhance to the chart, we are gonna show labels along the line.

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 dm.
// This is part view of the whole code. Click on copy icon to copy the whole code.
.data(dm)
.layers([
	{ mark: 'point' },
  	{ mark: 'line', interpolate: 'catmullRom' },
  	{
		mark: 'text',
       	encoding: {
			text: {
           		field: 'Displacement',
			},
          	color: { value : () => '#414141' },
        	background: {
				enabled: true,
           		padding: 5
        	}
       	},
       	encodingTransform: (points) => {
          	/* Eliminates overlapping by moving the points towards left or right */
         	for (let i = 0, len = points.length; i < len; i++) {
           		const currentPoint = points[i];
           		const nextPoint = points[i + 1];
           		if (i === len - 1) {
                  	/* Push the last point a bit towards the up */
             		currentPoint.update.y -= 10;
           		} else {
             		const diff = currentPoint.update.y - nextPoint.update.y;
             		if (diff > 0) {
                      	/* For positive slope, i.e. the y postion of current point is more than the
                      	 * y position of next point (svg co-ordinate origin starts from left top corner),
                         * move the labes towards the left, for eliminating overlapping
                         */
               			currentPoint.update.x -= 20; /* Tentative label width */
             		} else {
                      	/* For negative slope, i.e. the y postion of current point is less than the
                      	 * y position of next point (svg co-ordinate origin starts from left top corner),
                         * move the labes towards the right, for eliminating overlapping
                         */
               			currentPoint.update.x += 20; /* Tentative label width */
             		}
           		}
         	}
         	return points;
       	}
    }
])
.rows(['Displacement'])
.columns(['Year'])

We just added a text layer to show the labels along the line with a very simple overlapping elimination logic by translating the labels using encodingTransform property.

Right now, what we have here is an anchored line chart with labels.

In reality, you would need this layer almost everywhere. Imagine you have bar layer and you want to draw this anchored line chart with labels, or you have an area chart and you want to draw this anchored line chart with labels on top it, what would you do?

One way is to copy this bunch layers everywhere you need. But that does not feel very logical. Ideally, its preferred to have these layers defined as an library and you import those libraries to use custom layers.

Muze allows us to register a layer with is a composition of other layers and use it anywhere we want. Lets take a look at the following code.

layerFactory.composeLayers('anchoredLineWithText', [
    { /* line layer definition goes here */ },
    { /* point layer definition goes here */ },
    { /* text layer definition goes here */ },
])

All we have done is we used those three layers of anchored line chart with labels and created a composed label named anchoredLineWithText which we can use along side any other layers. Like

canvas.layers([{ mark: 'bar' }, { mark: 'anchoredLineWithText' }])

or

canvas.layers([{ mark: 'area' }, { mark: 'anchoredLineWithText' }])

or

canvas.layers([{ mark: 'myYetAnotherCustomLayer' }, { mark: 'anchoredLineWithText' }])

or

layerFactory.composeLayers('anchoredErrorLineWithText', [
    { /* tick layer to show error value */ },
    { /* anchoredLineWithText layer definition goes here */ },
])
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 dm.
// This is part view of the whole code. Click on copy icon to copy the whole code.

const share = muze.Operators.share;
const layerFactory = muze.layerFactory;

layerFactory.composeLayers('anchoredLineWithText', [
    {
        mark: 'point', 
        encoding: {
            x: 'anchoredLineWithText.encoding.x',
            y: 'anchoredLineWithText.encoding.y',
          	color: 'anchoredLineWithText.encoding.color'
        }
    },
    {
        mark: 'line', 
        interpolate: 'catmullRom',
        encoding: {
            x: 'anchoredLineWithText.encoding.x',
            y: 'anchoredLineWithText.encoding.y',
			color: 'anchoredLineWithText.encoding.color'
        }
    },
    {
        mark: 'text',
        encoding: {
            x: 'anchoredLineWithText.encoding.x',
            y: 'anchoredLineWithText.encoding.y',
            text: {
                field: 'anchoredLineWithText.encoding.text.field',
                formatter: val => Math.round(val)
            },
            color: { value : () => '#414141' },
            background: {
                enabled: true,
                padding: 5
            }
        },
        encodingTransform: (points) => {
            /* Eliminates overlapping by moving the points towards left or right */
            for (let i = 0, len = points.length; i < len; i++) {
                const currentPoint = points[i];
                const nextPoint = points[i + 1];
                if (i === len - 1) {
                    /* Push the last point a bit towards the up */
                    currentPoint.update.y -= 10;
                } else {
                    const diff = currentPoint.update.y - nextPoint.update.y;
                    if (diff > 0) {
                        /* For positive slope, i.e. the y postion of current point is more than the
                         * y position of next point (svg co-ordinate origin starts from left top corner),
                         * move the labes towards the left, for eliminating overlapping
                         */
                        currentPoint.update.x -= 15; /* Tentative label width */
                    } else {
                        /* For negative slope, i.e. the y postion of current point is less than the
                         * y position of next point (svg co-ordinate origin starts from left top corner),
                         * move the labes towards the right, for eliminating overlapping
                         */
                        currentPoint.update.x += 15; /* Tentative label width */
                    }
                }
            }
            return points;
        }
])

dm = dm.calculateVariable({
    name: 'min_acc',
    type: 'measure',
    defAggFn: 'min'
}, ['Acceleration', val => val]); // Max value of acceleration for a group

dm = dm.calculateVariable({
    name: 'max_acc',
    type: 'measure',
    defAggFn: 'max'
}, ['Acceleration', val => val]); // Min value of acceleration for a group

const env = muze();
env.data(dm);
                     
const canvasLeft = env.canvas();
const canvasRight = env.canvas();

canvasLeft
    .layers([
      {
            mark: 'area',
            interpolate: 'catmullRom',
            encoding: {
                color: { value: () => '#009688' }
            }
        },
      {
            mark: 'anchoredLineWithText',
            encoding: { 
              	text: { field: 'Displacement' },
                color: { value: () => '#007167' }
            }
        }
    ])
    .rows(['Displacement'])
    .columns(['Year'])
    .mount('.left-con');

canvasRight
    .layers([
        {
            mark : 'area',
            interpolate: 'catmullRom', /* spline */
            encoding: {
                y: 'max_acc',
                y0: 'min_acc',
                color: { value: () => '#ff9800'}
            }
        },
        {
            mark: 'anchoredLineWithText',
            encoding: {
                y: 'Acceleration', /* For shared variables, mention which field goes in y */
                text: { field: 'Acceleration' },
                color: { value: () => '#f57c00' }
            }
        }
    ])
    .rows([share('max_acc', 'min_acc', 'Acceleration') /* create a shared varaibles to be plotted on same axis */])
    .columns(['Year'])
    .mount('.right-con');

If you look at the above example, you will see that we have defined anchoredLineWithText once but used the same layer for different visualization along with different layers. You can manage every markers, annotations, theme features like the way we have done it here.

Wrapping up

With this we have completed the use and use cases of layers. Feel free to edit the examples and see for yourself. Layers can do much more than this, we have touched the point that all the layers are interactive since they takes instance of DataModel which is a part of the network DataModel creates every time we run an operation. The next chapter explains interactivity in general.