Skip to content
© 2026 The ThingsBoard Authors
Try for free

ThingsBoard Cloud

Choose your data region

Your data stays in the region you choose, for residency and compliance. No credit card required.

Rather run it yourself? Install on your own servers

Advanced Topics

Advanced techniques for widget development — external chart libraries, inter-widget communication, debugging, dark mode, and report integration.

For visualizations beyond what the built-in chart widget offers, integrate external libraries like ECharts. Add the library URL in the Resources tab, then use it in your JavaScript.

Resources tab: Add https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js

HTML:

<div class="chart-wrapper">
<div id="my-chart"></div>
</div>

CSS:

.chart-wrapper {
width: 100%;
height: 100%;
}
#my-chart {
width: 100%;
height: 100%;
}

JavaScript:

let chart, option;
self.onInit = function() {
let element = self.ctx.$container[0].querySelector('#my-chart');
chart = echarts.init(element);
option = {
series: [{
type: 'gauge',
center: ['50%', '50%'],
startAngle: 225,
endAngle: -45,
min: 0,
max: 100,
progress: { show: true, width: 30 },
pointer: { show: false },
axisLine: { lineStyle: { width: 30 } },
axisTick: { show: false },
splitLine: {
distance: -52,
length: 14,
lineStyle: { width: 3, color: '#757575' }
},
axisLabel: { distance: -15, color: '#757575', fontSize: 20 },
detail: {
valueAnimation: true,
offsetCenter: [0, 0],
fontSize: 36,
fontWeight: 600,
formatter: function(value) {
let label = self.ctx.data[0]?.dataKey?.label || '';
let units = self.ctx.data[0]?.dataKey?.units || '';
return '{label|' + label + '}\n{value|' + value + units + '}';
},
rich: {
label: { fontSize: 16, lineHeight: 24, color: '#424242' },
value: { fontSize: 36, lineHeight: 44, color: '#171717', fontWeight: 600 }
}
},
data: [{ value: 0 }]
}]
};
chart.setOption(option);
};
self.onDataUpdated = function() {
if (self.ctx.data.length
&& self.ctx.data[0].data.length
&& self.ctx.data[0].data[0].length) {
let newValue = self.ctx.data[0].data[0][1];
option.series.forEach(s => s.data[0].value = newValue);
chart.setOption(option);
}
};
self.onResize = function() {
chart.resize();
};
self.onDestroy = function() {
chart.clear();
};

Key lifecycle integration:

  • onInit — echarts.init() on a DOM element, set initial options
  • onDataUpdated — update option.series[*].data and call chart.setOption()
  • onResize — chart.resize() to handle container size changes
  • onDestroy — chart.clear() to free memory

For widgets that need hourly, daily, or custom aggregation beyond what the platform provides:

function aggregateByHour(data, type) {
const grouped = {};
data.forEach(({ ts, value }) => {
// Round to nearest hour
const hourTs = Math.floor(ts / 3600000) * 3600000;
if (!grouped[hourTs]) grouped[hourTs] = [];
grouped[hourTs].push(+value);
});
return Object.entries(grouped).map(([ts, values]) => {
let result;
switch (type) {
case 'Sum': result = values.reduce((a, b) => a + b, 0); break;
case 'Avg': result = values.reduce((a, b) => a + b, 0) / values.length; break;
case 'Min': result = Math.min(...values); break;
case 'Max': result = Math.max(...values); break;
}
return { ts: Number(ts), value: result };
});
}

Use this for client-side aggregation — for example, when a heatmap needs hourly buckets from raw time-series data.

Widgets on the same dashboard can communicate through the Broadcast Service.

self.onInit = function() {
let $injector = self.ctx.$scope.$injector;
let broadcastService = $injector.get(
self.ctx.servicesMap.get('broadcastService'));
broadcastService.on('my-custom-event', (event, data) => {
self.ctx.$scope.receivedData = data;
self.ctx.detectChanges();
});
};

Events are typically sent using widget actions configured in the dashboard editor. In the widget action configuration:

  • Set the action type to “Custom action”
  • In the custom function, use broadcastService to emit events

Widgets listening for those events receive them and can react accordingly.

A common broadcast pattern is the dark mode toggle. The platform emits toggle-dark-mode when the dashboard theme changes:

broadcastService.on('toggle-dark-mode', () => {
let isDark = document.querySelector('.tb-dashboard-page')
?.classList.contains('dark');
if (isDark) {
// Switch to dark colors
option.series[0].detail.rich.label.color = 'rgba(255,255,255,0.87)';
option.series[0].detail.rich.value.color = '#FFF';
} else {
// Switch to light colors
option.series[0].detail.rich.label.color = '#424242';
option.series[0].detail.rich.value.color = '#171717';
}
chart.setOption(option);
});

Widgets should handle both light and dark dashboard themes.

Use ThingsBoard’s CSS custom properties for theme-aware colors:

.my-widget {
color: var(--tb-primary-500);
background: var(--tb-primary-50);
}

These custom properties automatically change values between light and dark themes.

Check the current theme at runtime:

function isDarkMode() {
return document.querySelector('.tb-dashboard-page')
?.classList.contains('dark') || false;
}

Or listen for theme changes via the broadcast service (see above).

Control the widget’s time window programmatically — useful for date navigation widgets:

let currentDate = moment().startOf('day');
self.ctx.$scope.goBack = function() {
currentDate = moment(currentDate).subtract(1, 'day').startOf('day');
let start = currentDate.valueOf();
let end = moment(currentDate).add(1, 'day').startOf('day').valueOf();
self.ctx.timewindowFunctions.onUpdateTimewindow(start, end);
};
self.ctx.$scope.goForward = function() {
currentDate = moment(currentDate).add(1, 'day').startOf('day');
let start = currentDate.valueOf();
let end = moment(currentDate).add(1, 'day').startOf('day').valueOf();
self.ctx.timewindowFunctions.onUpdateTimewindow(start, end);
};

When embedding a chart widget, delegate data updates:

self.onDataUpdated = function() {
self.ctx.$scope.timeSeriesChartWidget.onDataUpdated();
};
self.onLatestDataUpdated = function() {
self.ctx.$scope.timeSeriesChartWidget.onLatestDataUpdated();
};

Build widgets with multi-select dropdowns to filter both data keys and entities:

self.onInit = function() {
let allDataKeys = self.ctx.widget.config.datasources[0].dataKeys;
let allDatasources = self.ctx.datasources;
self.ctx.$scope.formGroup = self.ctx.$scope.fb.group({
datasources: ['', []],
keys: ['', []]
});
// Initialize with all selected
self.ctx.$scope.formGroup.patchValue({
datasources: allDatasources.map(ds => ds.entityId),
keys: allDataKeys.map(key => key.name)
});
// React to selection changes
self.ctx.$scope.formGroup.valueChanges.subscribe(value => {
createCustomSubscription(value.datasources, value.keys);
});
};

For the createCustomSubscription implementation, use the entityList filter type to filter by selected entity IDs:

function createCustomSubscription(entityList, dataKeyNames) {
const dataKeys = allDataKeys
.filter(key => dataKeyNames.includes(key.name));
const datasources = [{
type: 'entity',
dataKeys,
entityFilter: {
type: 'entityList',
entityType: 'DEVICE',
entityList: entityList
}
}];
// ... create subscription (see Embedded Chart tutorial)
}

Use console.log() in any lifecycle function — output appears in the browser’s DevTools console:

self.onDataUpdated = function() {
console.log('Data received:', self.ctx.data);
console.log('First value:', self.ctx.data[0]?.data[0]);
};

Add debugger; to pause execution and inspect variables in DevTools:

self.onInit = function() {
debugger; // Execution pauses here when DevTools is open
let settings = self.ctx.settings;
};
  • Sources panel — your widget JavaScript appears under (no domain) or as an eval’d script. Search for your function names.
  • Console panel — log output and errors from your widget appear here. Filter by “widget” if needed.
  • Elements panel — inspect the widget DOM. Your HTML template renders inside a container with the widget ID.
  • Network panel — monitor RPC calls, attribute requests, and subscription data.

Widgets can integrate with the report service for PDF/PNG export:

let reportService = self.ctx.reportService;
if (reportService) {
reportService.reportCallback = function() {
// Called when the widget is being rendered for a report
// Return a promise that resolves when the widget is ready
return new Promise(resolve => {
// Wait for data to load, charts to render, etc.
setTimeout(resolve, 1000);
});
};
}

This callback tells the report generator to wait until your widget has finished rendering before capturing the screenshot.