Unlock a world of possibilities! Login now and discover the exclusive benefits awaiting you.
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.
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));
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);
})();
});
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)))))`
],
});
})();
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!
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.