Skip to main content
Francis_Kabinoff
Former Employee
Former Employee

Check out how I made a radar chart for nebula.js with a custom picasso.js component.

I I built a nebula.js radar chart extension to demonstrate creating and using custom components with picasso.js. Check it out @ https://observablehq.com/@fkabinoff/nebula-js-picasso-js-radar-chart. Let's walk through it a bit.

Connecting to a Qlik app

This is the simple part.

 

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: 'sense-demo.qlik.com',
  appId: '97f72e1b-512e-4624-a2d1-f7df8b352959',
};
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.js

Now let's take a look at configuring nebula.js. All we do here is register the Qlik app with nebula.js and the (as of yet nonexistent) radar chart.

 

import { embed } from '@nebula.js/stardust';
import qlikAppPromise from './qlikApp';
import radarChartSn from './radar-chart-sn';

export default new Promise((resolve) => {
  (async () => {
    const qlikApp = await qlikAppPromise;
    const nebula = embed(qlikApp, {
      types: [{
        name: 'radar-chart',
        load: () => Promise.resolve(radarChartSn),
      }],
    });
    resolve(nebula);
  })();
});

 

 

Using it

In our entrypoint index.js we just render the chart by passing the type and the fields to the nebula render function. Note here for the fields I used 2 dimensions and 1 measure. The first dimension is the ID of each entity. The second dimension is a ValueList of the five attributes that each entity has a score for, which will be the vertices of the radar chart. And the measure is the expression that takes the values from the from the ValueList dimension and returns the score for that value. If you want to see what this ValueList stuff is all about, check it out @ https://help.qlik.com/en-US/qlikview/Subsystems/Client/Content/QV_QlikView/ChartFunctions/SyntheticD...

 

import nebulaPromise from './nebula';

(async () => {
  const nebula = await nebulaPromise;
  const viz = await nebula.render({
    element: document.querySelector('#radar'),
    type: 'radar-chart',
    fields: [
      'ID',
      { qDef: { qFieldDefs: [`=ValueList('Attribute 1', 'Attribute 2', 'Attribute 3', 'Attribute 4', 'Attribute 5')`] }},
      `=IF(ValueList('Attribute 1','Attribute 2','Attribute 3','Attribute 4','Attribute 5')='Attribute 1',Avg([Attribute 1]),IF(ValueList('Attribute 1','Attribute 2','Attribute 3','Attribute 4','Attribute 5')='Attribute 2',Avg([Attribute 2]),IF(ValueList('Attribute 1','Attribute 2','Attribute 3','Attribute 4','Attribute 5')='Attribute 3',Avg([Attribute 3]),IF(ValueList('Attribute 1','Attribute 2','Attribute 3','Attribute 4','Attribute 5')='Attribute 4',Avg([Attribute 4]),IF(ValueList('Attribute 1','Attribute 2','Attribute 3','Attribute 4','Attribute 5')='Attribute 5',Avg([Attribute 5]),0)))))`
    ],
  });
})();

 

 

The nebula.js radar chart extension with custom picasso.js component

Ok here's where the fun happens. There's a lot going on here. I left a few comments in the code, and you can read through the code here, or check it out @ https://observablehq.com/@fkabinoff/nebula-js-picasso-js-radar-chart.

 

const stardust = require('@nebula.js/stardust');
import picassojs from 'picasso.js';
import picassoQ from 'picasso-plugin-q';

export default function supernova() {
  // create picasso instance
  const picasso = picassojs();
  
  // register picassoQ plugin
  picasso.use(picassoQ);

  /* 
    Since the radar chart always needs to be a square we will set the logical width and logical height
    in the nebula.js extension layout settings. We also need to reference these values to calculate
    the x and y position of the vertices.
  */
  const logicalWidth = 200;
  const logicalHeight = 200;

  /*
    maxValue is the max value that the radar chart data can be.
    totalAxes is the number of attributes.
    both of these could be dynamically calculated from the data, but I'm being lazy here.
  */
  const maxValue = 20;
  const totalAxes = 5;

  const radians = 2 * Math.PI;

  // these functions are used to calculate the x and y coordinates of the vertices
  const calcX = (value, i) => logicalWidth / 4 * (1 - (parseFloat(Math.max(value, 0)) / maxValue) * Math.sin(i * radians / totalAxes)) + logicalWidth / 4;
  const calcY = (value, i) => logicalHeight / 4 * (1 - (parseFloat(Math.max(value, 0)) / maxValue) * Math.cos(i * radians / totalAxes)) + logicalHeight / 4;
  
  /* 
    Here's the custom picasso.js component. We'll use this below in the picasso.js chart settings to create
    both the layers and the labels.
  */
  picasso.component('radar', {
    require: ['renderer', 'resolver'],
    defaultSettings: {},
    render({ data }) {
      const { items } = this.resolver.resolve({
        data,
        settings: this.settings.settings,
      });

      // group the data into layers, with each entity ID getting a complete set of attributes
      const layers = items
        .reduce((layersObj, item) => {
          layersObj[item.data.value] = layersObj[item.data.value] || {
            items: [],
            fill: item.fill,
          };
          layersObj[item.data.value].items.push(item.data);
          return layersObj;
        }, {});

      // build the layer components
      const layerComponents = Object.values(layers)
        .reduce((layerComponentsArr, layer) => {
          const points = layer.items.map((item, i) => ({
            x: calcX(item.score.value, i),
            y: calcY(item.score.value, i),
          }));
          const d = points.reduce((dStr, point, i) => {
            if (i === 0) {
              dStr += `M${point.x},${point.y} `;
              return dStr;
            }
            if (i === points.length - 1) {
              dStr += `${point.x},${point.y}Z`;
              return dStr;
            }
            dStr += `${point.x},${point.y} `;
            return dStr;
          }, '');
          return [
            ...layerComponentsArr,
            {
              type: 'path',
              d: d,
              fill: layer.fill,
              opacity: 0.3,
            },
          ];
        }, []);

      // figure out the labels
      const labels = Object.values(layers)[0].items
        .map((item, i) => ({
          text: item.attr.label,
          x: calcX(maxValue, i),
          y: calcY(maxValue, i),
        }));
      
      // build the label components
      const labelComponents = Object.values(labels)
        .reduce((labelComponentsArr, label) => {
          const textSize = this.renderer.measureText({
            text: label.text,
            fontFamily: 'Arial',
            fontSize: '4px',
          });
          return [
            ...labelComponentsArr,
            {
              type: 'text',
              text: label.text,
              fontFamily: 'Arial',
              fontSize: '4px',
              x: label.x - textSize.width / 2,
              y: label.y + textSize.height / 2,
            },
          ];
        }, []);
        
      const components = [...layerComponents, ...labelComponents];
      return components;
    },
  });

  return {
    qae: {
      properties: {
        qHyperCubeDef: {
          qDimensions: [],
          qMeasures: [],
          qInitialDataFetch: [{ qWidth: 3, qHeight: 100 }],
          qSuppressZero: true,
          qSuppressMissing: true,
        },
        showTitles: true,
        title: '',
        subtitle: '',
        footnote: '',
      },
      data: {
        targets: [
          {
            path: '/qHyperCubeDef',
            dimensions: {
              min: 2,
              max: 2,
            },
            measures: {
              min: 1,
              max: 1,
            },
          },
        ],
      },
    },
    component() {
      const element = stardust.useElement();
      const layout = stardust.useStaleLayout();
      const rect = stardust.useRect();

      const [instance, setInstance] = stardust.useState();

      stardust.useEffect(() => {
        const p = picasso().chart({
          element,
          data: [],
          settings: {},
        });

        setInstance(p);

        return () => {
          p.destroy();
        };
      }, []);

      stardust.useEffect(() => {
        if (!instance) {
          return;
        }

        instance.update({
          data: [
            {
              type: 'q',
              key: 'qHyperCube',
              data: layout.qHyperCube,
            },
          ],
          settings: {
            strategy: {
              logicalSize: {
                width: logicalWidth,
                height: logicalHeight,
                preserveAspectRatio: true,
              }
            },
            scales: {
              color: { 
                data: {
                  extract: {
                    field: 'qDimensionInfo/0',
                  },
                },
                type: 'categorical-color',
                range: ['#0B84A5', '#F6C85F', '#6F4E7C', '#9DD866', '#CA472F']
              }
            },
            components: [
              {
                type: 'radar',
                data: {
                  extract: {
                    field: 'qDimensionInfo/0',
                    props: {
                      attr: {
                        field: 'qDimensionInfo/1',
                      },
                      score: {
                        field: 'qMeasureInfo/0',
                      },
                    },
                  },
                },
                settings: {
                  fill: (d) => d.resources.scale('color')(d.datum.value),
                },
              },
            ],
          },
        });
      }, [layout, instance]);

      stardust.useEffect(() => {
        if (!instance) {
          return;
        }
        instance.update();
      }, [rect.width, rect.height, instance]);
    },
  };
}

 

 

And that's it. The full project code is attached as well, if you'd like to download it and check it out that way. Let me know if you have any comments or questions!