Skip to main content
Announcements
Qlik Connect 2024! Seize endless possibilities! LEARN MORE
Ouadie
Employee
Employee

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.

Connecting to the Qlik Sense app

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));

 

 

Configuring Nebula

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);
  })();
});

 

 

Rendering the chart

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;

 

 

The slope chart extension

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:

  • Type "axis" - notice that we have two y-axes that use two different scales covered above
  • Type "lines" - notice that we're using the series prop that represents sectors
  • Type "point" - this represents the circles at the edges of the slope lines, notice that we're extracting some additional data here since we're gonna be using it for the tooltip component.
  • Type "tooltip" - there are three aspects to rendering tooltips:
    • Interaction to bind events to the chart. We use 'mousemove' and 'mouseleave' to show or hide the tooltip.
    • Extracting the relevant data from the hovered node, in this case we're filtering to look for nodes with key 'point', then we manipulate this data to return an object containing the values we will be displaying
    • Generating content using the 'content' setting to format the information from the object we previously constructed and generate virtual nodes using the HyperScript API.

 

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!