Discovery is a powerful dashboarding tool and allows for totally custom data visualization. Learn how to create a Discovery plugin.
Since Discovery-widgets 1.1.4, we integrate a plugin system in order to add your own tiles which will interact with Discovery Dashboards. It becomes quite easy to create a custom chart.
Tiles are based on Web Components, so your plugin must also be a Web Component.
In this tutorial, we will build a radar chart based upon the nice ChartJS library and embed it into a StencilJS Web Component.
What exactly is Discovery? Learn more |
Bootstrap the project
In a new folder, we will scaffold a StencilJS Web Component:
$ npm init stencil
$ cd discovery-plugin-radar
$ npm install
$ rm -fr src/components/*
$ npm install chart.js
$ npm install -D @senx/discovery-widgets
Bootstrap your component
$ npm run generate
In order to parse your package.json to add some of its fields in your plugin definition, edit tsconfig.ts
:
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"allowUnreachableCode": false,
[ ... ]
"jsxFactory": "h",
"resolveJsonModule": true
},
[ ... ]
}
Just add "resolveJsonModule": true
to the compilerOptions
.
To facilitate the packaging of your component, edit stencil.config.ts
:
import { Config } from '@stencil/core';
export const config: Config = {
namespace: 'discovery-plugin-radar',
globalScript: './src/plugin.ts',
outputTargets: [
{
type: 'dist',
esmLoaderPath: '../loader',
},
{
type: 'dist-custom-elements',
},
{
type: 'docs-readme',
},
{
type: 'www',
serviceWorker: null, // disable service workers
},
],
};
We add a globalScript
field to allow your component to be registered by Discovery.
Create src/plugin.ts
to allow your component to be registered:
import {PluginDef, PluginManager} from "@senx/discovery-widgets";
import * as pack from "../package.json"
export default () => {
PluginManager.getInstance().register(new PluginDef({
type: 'radar',
name: pack.name,
tag: 'discovery-plugin-radar',
author: pack.author,
description: pack.description,
version: pack.version,
}));
}
This code will be called when the browser loads the component. It creates a plugin definition:
- type: the chart type that you will use in your dashboards tiles definition (must be unique across Discovery)
- name: we fetch the package name as declared in package.json, it will act as a unique key to register your plugin
- tag: the HTML tag corresponding to your component
- author: we fetch the author field of the package.json file (do not forget to fill it)
- description: we fetch the description field of the package.json file (do not forget to fill it)
- version: your package version
The radar component
Stencil should have created 2 files:
- src/components/discovery-plugin-radar/discovery-plugin-radar.css
- src/components/discovery-plugin-radar/discovery-plugin-radar.tsx
Edit src/components/discovery-plugin-radar/discovery-plugin-radar.css
:
:host {
display: block;
width: 100%;
height: 100%;
}
.chart-container {
width: 100%;
height: 100%;
}
StencilJS also supports Less, Sass, Stylus, and PostCss (Learn more about StencilJS plugins).
Edit src/components/discovery-plugin-radar/discovery-plugin-radar.tsx
.
Start with the imports:
import { ChartType, ColorLib, DataModel, DiscoveryEvent, GTSLib, Logger, Param, Utils } from '@senx/discovery-widgets';
import { Component, Element, Event, EventEmitter, h, Listen, Method, Prop, State, Watch } from '@stencil/core';
import { Chart, registerables } from 'chart.js';
Now, have a look at the mandatory parts of a Discovery plugin.
The attributes
As it is a Web Component, you can define custom HTML attributes. Some of them are mandatory for a Discovery Plugin:
@Prop() result: DataModel | string; // mandatory, will handle the result of a Warp 10 script execution
@Prop() type: ChartType; // optionnal, to handle the chart type if you want to handle more than one
@Prop() options: Param | string = new Param(); // mandatory, will handle dashboard and tile option
@State() @Prop() width: number; // optionnal
@State() @Prop({ mutable: true }) height: number; // optionnal, mutable because, in this tutorial, we compute it
@Prop() debug: boolean = false; // optionnal, handy if you want to use the Discovery Logger
The events
As it will interact with other tiles, your component needs to emit events:
@Event() draw: EventEmitter<void>; // mandatory
Other inner variables
@Element() el: HTMLElement;
@State() innerOptions: Param; // will handle merged options
@State() innerResult: DataModel; // will handle the parsed execution result
private LOG: Logger; // The Discovery Logger
private divider: number = 1000; // Warp 10 time unit divider
private chartElement: HTMLCanvasElement; // The chart area
private innerStyles: any = {}; // Will handle custom CSS styles for your tile
private myChart: Chart; // The ChartJS instance
Watchers
Watchers will be called each time an attribute is updated. Those are mandatory:
/*
* Called when the result is updated
*/
@Watch('result') // mandatory
updateRes(newValue: DataModel | string, oldValue: DataModel | string) {
if (JSON.stringify(newValue) !== JSON.stringify(oldValue)) {
this.innerResult = GTSLib.getData(this.result);
setTimeout(() => this.drawChart()); // <- we will see this function later
}
}
/*
* Called when the options are updated
*/
@Watch('options') // mandatory
optionsUpdate(newValue: string, oldValue: string) {
if (JSON.stringify(newValue) !== JSON.stringify(oldValue)) {
if (!!this.options && typeof this.options === 'string') {
this.innerOptions = JSON.parse(this.options);
} else {
this.innerOptions = { ...this.options as Param };
}
setTimeout(() => this.drawChart());
}
}
Public methods
The dashboard can invoke some methods on tiles, some methods are mandatory:
/*
* Mandatory
* Called by Discovery when the component must be resized
*/
@Method()
async resize() {
if (!!this.myChart) {
this.myChart.resize();
}
}
/*
* Optionnal
* Called by Discovery when the component has to export its content to PNG or SVG
*/
@Method()
async export(type: 'png' | 'svg' = 'png') {
// insert your own implementation
}
Events handler
Those handlers will compute things when an event is trapped by the tile:
/* Handy if you want to handle Discovery events coming from other tiles */
@Listen('discoveryEvent', { target: 'window' })
discoveryEventHandler(event: CustomEvent<DiscoveryEvent>) {
const res = Utils.parseEventData(event.detail, this.innerOptions.eventHandler);
if (res.data) {
this.innerResult = res.data;
setTimeout(() => this.drawChart());
}
if (res.style) {
this.innerStyles = { ...this.innerStyles, ...res.style as { [k: string]: string } };
}
}
Component lifecycle methods
As your component will be loaded in a browser, StencilJS provide handy lifecycle methods (learn more):
/*
* Mandatory
* Part of the lifecycle
*/
componentWillLoad() {
Chart.register(...registerables); // ChartJS specific loading
this.LOG = new Logger(DiscoveryPluginRadar, this.debug); // init the Discovery Logger
// parse options
if (typeof this.options === 'string') {
this.innerOptions = JSON.parse(this.options);
} else {
this.innerOptions = this.options;
}
// parse result
this.innerResult = GTSLib.getData(this.result);
this.divider = GTSLib.getDivider(this.innerOptions.timeUnit || 'us'); // Warp 10 default time unit
// Get tile dimensions of the container
const dims = Utils.getContentBounds(this.el.parentElement);
this.width = dims.w;
this.height = dims.h;
}
/*
* Mandatory
* Part of the lifecycle
*/
componentDidLoad() {
setTimeout(() => this.drawChart());
}
/*
* Mandatory
* Render the content of the component
*/
render() {
return (
<div class="chart-container">
{this.innerResult ? <canvas id="myChart" ref={(el) => this.chartElement = el as HTMLCanvasElement}></canvas> : ''}
</div>
);
}
The logic part
Now, I will show you how to handle the execution result and feed ChartJS with it (learn more about the data structure of ChartJS) :
drawChart() {
// Merge options
let options = Utils.mergeDeep<Param>(this.innerOptions || {} as Param, this.innerResult.globalParams) as Param;
this.innerOptions = { ...options };
const labels = [];
const datasets = [];
// Flatten the data structure and add an id to GTS
const gtsList = GTSLib.flattenGtsIdArray(this.innerResult.data as any[], 0).res;
// For each GTS
gtsList.forEach((gts, i) => {
// if the GTS is a list of values
if (GTSLib.isGtsToPlot(gts)) {
const data = [];
// Compute the GTS color
const c = ColorLib.getColor(gts.id || i, this.innerOptions.scheme);
const color = ((this.innerResult.params || [])[i] || { datasetColor: c }).datasetColor || c;
// For each value
gts.v.forEach(d => {
// Handle date depending on the timeMode and the timeZone
const date = GTSLib.utcToZonedTime(d[0], this.divider, this.innerOptions.timeZone);
const dateLabel = (this.innerOptions.timeMode || 'date') === 'date'
? GTSLib.toISOString(GTSLib.zonedTimeToUtc(date, 1, this.innerOptions.timeZone), 1, this.innerOptions.timeZone, this.innerOptions.timeFormat)
.replace('T', '\n').replace(/\+[0-9]{2}:[0-9]{2}$/gi, '')
: date;
// add the label
if (!labels.includes(dateLabel)) {
labels.push(dateLabel);
}
// add the value
data.push(d[d.length - 1]);
});
// add the dataset
datasets.push({
label: ((this.innerResult.params || [])[i] || { key: undefined }).key || GTSLib.serializeGtsMetadata(gts),
data,
borderColor: color,
backgroundColor: ColorLib.transparentize(color, 0.5)
})
}
});
if (!!this.chartElement) {
const ctx = this.chartElement.getContext('2d');
if (!this.myChart) {
this.myChart = new Chart(ctx, {
type: 'radar',
data: { labels, datasets },
options: {
animation: false,
responsive: true,
maintainAspectRatio: false
}
});
} else {
this.myChart.data = { labels, datasets };
this.myChart.update();
}
}
}
That is all.
Test and run
At first edit src/index.html
:
<!DOCTYPE html>
<html dir="ltr" lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0" />
<title>Stencil Component Starter</title>
<!-- Import Discovery -->
<script nomodule src="https://unpkg.com/@senx/discovery-widgets/dist/discovery/discovery.js"></script>
<script type="module" src="https://unpkg.com/@senx/discovery-widgets/dist/discovery/discovery.esm.js"></script>
<!-- Import your plugin -->
<script type="module" src="./build/discovery-plugin-radar.esm.js"></script>
<script nomodule src="./build/discovery-plugin-radar.js"></script>
</head>
<body>
<!-- Define a one tile dashboard with "radar" as a chart type and random values -->
<discovery-dashboard url="https://sandbox.senx.io/api/v0/exec" dashboard-title="Test" debug>
{
'title' 'Test'
'description' 'Dashboard test'
'tiles' [
{
'title' 'test'
'x' 0 'y' 0 'w' 12 'h' 2
'type' 'radar'
'macro' <%
NOW 'now' STORE
1 4 <%
DROP NEWGTS 'g' STORE
1 10 <%
'ts' STORE $g $ts STU * $now + NaN NaN NaN RAND ADDVALUE DROP
%> FOR
$g
%> FOR
%>
}
]
}
</discovery-dashboard>
</body>
</html>
And now, start your dev server:
$ npm run start
Here you are:
Going further
Now, you can develop your own tiles and charts. Feel free to publish them on NPMjs.org.
Let us know about your creations.
A final thought
But how to use the plugin mechanism with Discovery Explorer? It handles plugins since 1.0.12.
Create a conf file, like conf.json
:
{
"dashRoot": "/data",
"plugins": [
{
"name": "radar-module",
"src": "https://unpkg.com/discovery-plugin-radar/dist/discovery-plugin-radar/discovery-plugin-radar.esm.js",
"isModule": true
},
{
"name": "radar-nomodule",
"src": "https://unpkg.com/discovery-plugin-radar/dist/discovery-plugin-radar/discovery-plugin-radar.js",
"isModule": false
}
]
}
The "dashRoot"
field must remain like that.
The conf file defines a list of plugins to include, the module version and the nomodule version like we saw in the previous index.html
file.
And now start the docker image:
$ docker run --rm -d -p9090:3000 \
-v /path/to/your/conf.json:/opt/discovery-dashboard/conf.json \
-v /path/to/your/dashboards:/data discovery-test:latest
Discover more articles and tutorials about Discovery.
Read more
Trace Plugin: save time with the WarpScript code debugger
Thrift and Protocol Buffers support in WarpScript
Warp 10 Docker Image - Next Generation
Senior Software Engineer