Unlock a world of possibilities! Login now and discover the exclusive benefits awaiting you.
In this article, we will explore creating a slope chart extension using Nebula.js and the Picasso.js charting library. We will walk through the process, explain the different Picasso components that make up the chart, and introduce a few concepts along the way including tooltips and brushing.
Documentation for both libraries can be found here:
The slope chart we're about to create was featured on the 2021 Fortune 500 app. It visually explains how sectors have been impacted by the COVID-19 pandemic by ranking the sectors of the Fortune 500 list and showing their increase or decrease between 2020 and 2021.
First things first, let's connect to our QS app using Enigma.js. We create a QIX session using "Enigma.create" then use the "Session.open" function to establish the websocket connection and get access to the Global instance. We use the "openDoc" method within the global context to make the app ready for interaction.
// qlikApp.js
const enigma = require('enigma.js');
const schema = require('enigma.js/schemas/12.170.2.json');
const SenseUtilities = require('enigma.js/sense-utilities');
const config = {
host: '<HOST URL>',
appId: '<APP ID>',
};
const url = SenseUtilities.buildUrl(config);
const session = enigma.create({ schema, url });
session.on('closed', () => {
console.error('Qlik Sense Session ended!');
const timeoutMessage = 'Due to inactivity, the story has been paused. Refresh to continue.';
alert(timeoutMessage);
});
export default session.open().then((global) => global.openDoc(config.appId));
Now that we have successfully connected to the QS app, let's move on to configuring Nebula.js. In this step, we use the "embed" method to initiate a new Embed instance using the enigma app. We then register the chart extension named "slope" (the actual creation of this extension is covered further down).
// nebula.js
import { embed } from '@nebula.js/stardust';
import qlikAppPromise from 'config/qlikApp';
import slope from './fortune-slope-sn';
export default new Promise((resolve) => {
(async () => {
const qlikApp = await qlikAppPromise;
const nebula = embed(qlikApp, {
types: [{
name: 'slope',
load: () => Promise.resolve(slope),
}],
});
resolve(nebula);
})();
});
In our "Slope" react component, we proceed to render the visualization into the DOM on the fly. We use the "render" method and pass configuration options that include a reference to the HTML element, the type (we named it "slope" in the previous step), and the array of fields. In this case, we use 2 dimensions (Year, Sector) and 2 measures (Set Analysis that returns the ranking by sector profits as well as the actual profit numbers for the two years we're interested in).
import React, { useRef, useEffect } from 'react';
import useNebula from 'hooks/useNebula';
const Slope = () => {
const elementRef = useRef();
const chartRef = useRef();
const nebula = useNebula();
useEffect(async () => {
if (!nebula) return;
chartRef.current = await nebula.render({
element: elementRef.current,
type: 'slope',
fields: [
'[Issue Published Year]',
'[Sector2]',
'=Rank(Sum({$<[Issue Published Year]={2020, 2021}>} [Inflation Adjusted Sector Profit]))',
'=Sum({$<[Issue Published Year]={2020, 2021}>} [Inflation Adjusted Sector Profit])',
],
});
}, [nebula]);
return (
<div>
<div id="slopeViz" ref={elementRef} style={{ height: 600, width: 800 }} />
</div>
);
};
export default Slope;
This is where the magic happens! Let's explore different sections of the file and go through them (the full project can be found at the end of the article).
In the following code snippet, we make use of the q plugin that makes it easier to extract data from a QIX hypercube (or alternatively a list object). Notice the values of the initial fetch and the min and max properties of the dimensions and measures, these should match the number of fields we previously set in our Slope react component.
export default function supernova() {
const picasso = picassojs();
picasso.use(picassoQ);
return {
qae: {
properties: {
qHyperCubeDef: {
qDimensions: [],
qMeasures: [],
qInitialDataFetch: [{ qWidth: 4, qHeight: 2500 }],
qSuppressZero: false,
qSuppressMissing: true,
},
showTitles: true,
title: '',
subtitle: '',
footnote: '',
},
data: {
targets: [
{
path: '/qHyperCubeDef',
dimensions: {
min: 1,
max: 2,
},
measures: {
min: 1,
max: 2,
},
},
],
},
},
...
Scales
Our x scale is related to the year field, the color scale represents our second dimension - sectors, and lastly the y and y-end scales use custom "ticks" values because we would like to show labels in the format "rank # - sector".
Both "yaxisVals" and "yaxisendVals" arrays have been constructed by manipulating the data extracted from the layout object (see lines 76 to 92 of the slope-sn.js file).
You can learn more about scales and the different types of scales that the Picasso library offers here.
scales: {
x: {
data: {
extract: {
field: 'qDimensionInfo/0',
},
},
paddingInner: 0.8,
paddingOuter: 0,
},
color: {
data: {
extract: {
field: 'qDimensionInfo/1',
},
},
range: ['#5D627E'],
type: 'color',
},
y: {
data: {
field: 'qMeasureInfo/0',
},
invert: false,
expand: 0.03,
type: 'linear',
ticks: { values: yaxisVals },
},
yend: {
data: {
field: 'qMeasureInfo/0',
},
invert: false,
expand: 0.03,
type: 'linear',
ticks: { values: yaxisendVals },
},
},
...
Components
The components that make up the chart are:
components: [
{
type: 'axis',
key: 'x-axis',
scale: 'x',
dock: 'bottom',
settings: {
labels: {
show: true,
fontSize: '10px',
mode: 'horizontal',
},
},
},
{
type: 'axis',
key: 'y-axis',
scale: 'y',
settings: {
labels: {
show: true,
mode: 'layered',
fontSize: '10px',
filterOverlapping: false,
},
},
layout: {
show: true,
dock: 'left',
minimumLayoutMode: 'S',
},
},
{
type: 'axis',
key: 'y-axis-end',
scale: 'yend',
settings: {
labels: {
show: true,
mode: 'layered',
fontSize: '10px',
filterOverlapping: false,
},
},
layout: {
show: true,
dock: 'right',
},
},
{
type: 'line',
key: 'lines',
data: {
extract: {
field: 'qDimensionInfo/0',
props: {
y: {
field: 'qMeasureInfo/0',
},
series: {
field: 'qDimensionInfo/1',
},
},
},
},
settings: {
coordinates: {
major: {
scale: 'x',
},
minor: {
scale: 'y',
ref: 'y',
},
minor0: {
scale: 'y',
},
layerId: {
ref: 'series',
},
},
orientation: 'horizontal',
layers: {
sort: (a, b) => a.id - b.id,
curve: 'monotone',
line: {
stroke: {
scale: 'color',
ref: 'series',
},
strokeWidth: 2,
opacity: 0.8,
},
},
},
brush: {
consume: [{
context: 'increase',
style: {
active: {
stroke: '#53A4B1',
opacity: 1,
},
inactive: {
stroke: '#BEBEBE',
opacity: 0.45,
},
},
},
{
context: 'decrease',
style: {
active: {
stroke: '#A7374E',
opacity: 1,
},
inactive: {
stroke: '#BEBEBE',
opacity: 0.45,
},
},
}],
},
},
{
type: 'point',
key: 'point',
displayOrder: 1,
data: {
extract: {
field: 'qDimensionInfo/0',
props: {
x: {
field: 'qDimensionInfo/0',
},
y: {
field: 'qMeasureInfo/0',
},
ind: {
field: 'qDimensionInfo/1',
},
rank: {
field: 'qMeasureInfo/0',
},
rev: {
field: 'qMeasureInfo/1',
},
},
},
},
settings: {
x: { scale: 'x' },
y: { scale: 'y' },
shape: 'circle',
size: 0.2,
strokeWidth: 2,
stroke: '#5D627E',
fill: '#5D627E',
opacity: 0.8,
},
brush: {
consume: [{
context: 'increase',
style: {
active: {
fill: '#53A4B1',
stroke: '#53A4B1',
opacity: 1,
},
inactive: {
fill: '#BEBEBE',
stroke: '#BEBEBE',
opacity: 0.45,
},
},
},
{
context: 'decrease',
style: {
active: {
fill: '#A7374E',
stroke: '#A7374E',
opacity: 1,
},
inactive: {
fill: '#BEBEBE',
stroke: '#BEBEBE',
opacity: 0.45,
},
},
}],
},
},
{
key: 'tooltip',
type: 'tooltip',
displayOrder: 10,
settings: {
// Target point marker
filter: (nodes) => nodes.filter((node) => node.key === 'point' && node.type === 'circle'),
// Extract data
extract: ({ node, resources }) => {
const obj = {};
obj.year = node.data.x.label;
obj.industry = node.data.ind.label;
obj.rank = node.data.rank.value;
obj.rankchange = rankChange[obj.industry];
obj.profitsChange = profitsChange[obj.industry];
obj.profits = resources.formatter({ type: 'd3-number', format: '.3s' })(node.data.rev.value);
return obj;
},
// Generate tooltip content
content: ({ h, data }) => {
const els = [];
let elarrow = null;
let rankCh = '';
data.forEach((node) => {
// Title
const elh = h('td', {
colspan: '3',
style: { fontWeight: 'bold', 'text-align': 'left', padding: '0 5px' },
}, `${node.year} ${node.industry}`);
const el1 = h('td', { style: { padding: '0 5px' } }, 'Rank');
const el2 = h('td', { style: { padding: '0 5px' } }, `#${node.rank}`);
// Rank Change
if (node.rankchange > 0 && node.year !== '2020') {
rankCh = `+${node.rankchange}`;
elarrow = h('div', {
style: {
width: '0px', height: '0px', 'border-left': '5px solid transparent', 'border-right': '5px solid transparent', 'border-bottom': '5px solid #008000',
},
}, '');
} else if (node.rankchange < 0 && node.year !== '2020') {
rankCh = node.rankchange;
elarrow = h('div', {
style: {
width: '0px', height: '0px', 'border-left': '5px solid transparent', 'border-right': '5px solid transparent', 'border-top': '5px solid #FF0000',
},
}, '');
} else {
rankCh = '';
elarrow = '';
}
// Rest of Info
const el3 = h('td', {
style: {
display: 'flex',
alignItems: 'center',
},
}, [rankCh, elarrow]);
const elr1 = h('tr', {}, [el1, el2, el3]);
const elr2 = h('tr', {}, [h('td', { style: { padding: '0 5px' } }, 'Profits:'), h('td', { style: { padding: '0 5px' } }, node.profits.replace(/G/, 'B')), h('td', {}, (node.year !== '2020') ? `${numeral(node.profitsChange).format('+0a').toUpperCase()}` : '')]);
els.push(h('tr', {}, [elh]), elr1, elr2);
});
return h('table', {}, els);
},
placement: {
type: 'pointer',
area: 'target',
dock: 'auto',
},
},
},
],
interactions: [
{
type: 'native',
events: {
mousemove(e) {
this.chart.component('tooltip').emit('show', e);
},
mouseleave() {
this.chart.component('tooltip').emit('hide');
},
},
},
],
Brushing
In the code above, you will notice 'brush' settings on both the "lines" and "point" type components. We observe changes of a particular brush context (in this case we have two contexts, one named "increase" to show increasing lines and one for "decrease" to show lines that represent sectors that have fallen in ranks).
The active and inactive properties contain styles to be applied to the component when it is brushed.
In our scenario, we want to programmatically control these brushes from our Slope react component through a toggle button. Let's modify the Slope.jsx file to reflect that.
Notice that we are accessing the "increase" and "decrease" brushes through the global window object containing the Picasso chart instance (we assign this on line 456 of slope-sn.js). We then use a combination of the "start", "clear", "end", and "addValues" methods to react to our "toggleBrush" state changes when one of the buttons is clicked.
import React, { useRef, useEffect, useState } from 'react';
import useNebula from 'hooks/useNebula';
import Button from '@material-ui/core/Button';
const Slope = () => {
const elementRef = useRef();
const chartRef = useRef();
const nebula = useNebula();
const [toggleBrush, setToggleBrush] = useState(false);
const increaseValues = [11, 16, 17, 5, 8, 12];
const decreaseValues = [4, 6, 19];
useEffect(async () => {
if (!nebula) return;
chartRef.current = await nebula.render({
element: elementRef.current,
type: 'slope',
fields: [
'[Issue Published Year]',
'[Sector2]',
'=Rank(Sum({$<[Issue Published Year]={2020, 2021}>} [Inflation Adjusted Sector Profit]))',
'=Sum({$<[Issue Published Year]={2020, 2021}>} [Inflation Adjusted Sector Profit])',
],
});
}, [nebula]);
useEffect(() => {
if (!nebula || !window.slopeInstance) return;
const highlighterIncrease = window.slopeInstance.brush('increase');
const highlighterDecrease = window.slopeInstance.brush('decrease');
highlighterIncrease.start();
highlighterIncrease.clear();
highlighterDecrease.start();
highlighterDecrease.clear();
if (toggleBrush) {
highlighterIncrease.addValues(increaseValues.map((val) => ({ key: 'qHyperCube/qDimensionInfo/1', value: val })));
} else {
highlighterDecrease.addValues(decreaseValues.map((val) => ({ key: 'qHyperCube/qDimensionInfo/1', value: val })));
}
}, [toggleBrush]);
const handleClearBrushes = () => {
if (!nebula || !window.slopeInstance) return;
const highlighterIncrease = window.slopeInstance.brush('increase');
const highlighterDecrease = window.slopeInstance.brush('decrease');
highlighterIncrease.clear();
highlighterIncrease.end();
highlighterDecrease.clear();
highlighterDecrease.end();
};
return (
<div>
<div id="slopeViz" ref={elementRef} style={{ height: 600, width: 800 }} />
<Button onClick={() => setToggleBrush(!toggleBrush)}>{toggleBrush ? 'Highlight Decrease' : 'Highlight Increasae'}</Button>
<Button onClick={() => handleClearBrushes()}>Clear Brushes</Button>
</div>
);
};
export default Slope;
You can check out the full project code on Github.
Don't forget to take a look at this year's Fortune 500 and Global 500 apps that feature this chart as well as other custom ones all made possible with Nebula.js and Picasso.js!
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.