@highcharts/dashboards
Version:
Highcharts Dashboards framework
502 lines (501 loc) • 13.7 kB
JavaScript
/* *
*
* (c) 2009-2025 Highsoft AS
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* Authors:
* - Sebastian Bochan
* - Wojciech Chmiel
* - Gøran Slettemark
* - Sophie Bremer
*
* */
'use strict';
import AST from '../../../Core/Renderer/HTML/AST.js';
import Component from '../Component.js';
import KPISyncs from './KPISyncs/KPISyncs.js';
import KPIComponentDefaults from './KPIComponentDefaults.js';
import SUM from '../../../Data/Formula/Functions/SUM.js';
import AVERAGE from '../../../Data/Formula/Functions/AVERAGE.js';
import MEDIAN from '../../../Data/Formula/Functions/MEDIAN.js';
import MAX from '../../../Data/Formula/Functions/MAX.js';
import MIN from '../../../Data/Formula/Functions/MIN.js';
import COUNT from '../../../Data/Formula/Functions/COUNT.js';
import PRODUCT from '../../../Data/Formula/Functions/PRODUCT.js';
import Templating from '../../../Core/Templating.js';
const { format } = Templating;
import U from '../../../Core/Utilities.js';
const { createElement, css, defined, diffObjects, isArray, isNumber, merge, isFunction } = U;
/* *
*
* Class
*
* */
/**
*
* Class that represents a KPI component.
*
*/
class KPIComponent extends Component {
/* *
*
* Constructor
*
* */
/**
* Creates a KPI component in the cell.
*
* @param cell
* Instance of cell, where component is attached.
*
* @param options
* The options for the component.
*/
constructor(cell, options, board) {
options = merge(KPIComponent.defaultOptions, options);
super(cell, options, board);
this.options = options;
this.type = 'KPI';
this.value = createElement('span', {
className: `${options.className}-value`
}, {}, this.contentElement);
this.subtitle = createElement('span', {
className: this.getSubtitleClassName()
}, {}, this.contentElement);
}
/* *
*
* Functions
*
* */
/** @internal */
async load() {
await super.load();
this.linkValueToChart();
return this;
}
resize(width, height) {
super.resize(width, height);
// Animate
if (this.chart && this.chart.container) {
this.chart.reflow();
}
return this;
}
render() {
super.render();
this.updateElements();
const charter = KPIComponent.charter?.Chart;
if (charter &&
this.options.chartOptions &&
!this.chart) {
if (!this.chartContainer) {
this.chartContainer = createElement('div', {
className: `${this.options.className}-chart-container`
}, {
// Fix inner height, when using flex box
padding: '0.1px'
}, this.contentElement);
}
this.chart = charter.chart(this.chartContainer, merge(KPIComponent.defaultChartOptions, this.options.chartOptions));
}
else if (this.chart &&
!this.options.chartOptions &&
'chartOptions' in this.options) {
this.chart.destroy();
this.chart = void 0;
}
this.sync.start();
this.emit({ type: 'afterRender' });
return this;
}
/**
* Handles updating via options.
*
* @param options
* The options to apply.
*/
async update(options, shouldRerender = true) {
await super.update(options);
if (options.chartOptions && this.chart) {
this.chart.update(options.chartOptions);
}
shouldRerender && this.render();
}
/**
* @internal
*/
onTableChanged() {
this.setValue();
}
/**
* Destroys the highcharts component.
*/
destroy() {
// Cleanup references in the global Highcharts scope
this.chart?.destroy();
super.destroy();
}
/**
* Gets a proper value, according to the provided formula option.
*
* @returns
* The formula value. Can be a number internally, or a string from the
* callback function.
*
* @internal
*/
getFormulaValue() {
const formula = this.options.formula;
const connector = this.getFirstConnector();
const table = connector?.table.modified;
const column = table?.getColumn(this.options.columnName);
if (!column || !formula) {
return;
}
if (isFunction(formula)) {
return formula.call(this, column);
}
let filteredColumn = Array.isArray(column) ?
column.slice().filter(defined) : Array.from(column);
// Filter NaN values and empty strings since the formula functions don't
// handle it internally.
if (formula === 'MIN' || formula === 'MAX' || formula === 'MEDIAN') {
filteredColumn = filteredColumn.filter((val) => val !== '' && !isNaN(Number(val)));
}
// Sort values since the formula function don't handle it internally.
if (formula === 'MEDIAN') {
filteredColumn.sort((a, b) => Number(a) - Number(b));
}
try {
return KPIComponent.formulaFunctions[formula](filteredColumn);
}
catch {
console.warn('Invalid formula option provided.'); // eslint-disable-line no-console
}
}
/**
* Gets the default value that should be displayed in the KPI.
*
* @returns
* The value that should be displayed in the KPI.
*/
getValue() {
if (defined(this.options.value)) {
return this.options.value;
}
const connector = this.getFirstConnector();
if (connector && this.options.columnName) {
if (defined(this.options.formula)) {
return this.getFormulaValue();
}
const table = connector.table.modified, column = table.getColumn(this.options.columnName), length = column?.length || 0;
return table.getCellAsString(this.options.columnName, length - 1);
}
}
/**
* Sets the value that should be displayed in the KPI.
*
* @param value
* The value to display in the KPI.
*/
setValue(value = this.getValue()) {
const { valueFormat, valueFormatter } = this.options;
if (defined(value)) {
let prevValue;
if (isNumber(+value)) {
prevValue = +value;
}
if (valueFormatter) {
value = valueFormatter.call(this, value);
}
else if (valueFormat) {
value = format(valueFormat, { value });
}
else if (isNumber(value)) {
value = value.toLocaleString();
}
AST.setElementHTML(this.value, '' + value);
this.linkValueToChart(prevValue);
this.prevValue = prevValue;
}
}
/**
* Handles updating chart point value.
*
* @internal
*/
linkValueToChart(value = this.getValue()) {
const chart = this.chart;
const linkedValueTo = this.options.linkedValueTo;
if (!chart || !linkedValueTo.enabled ||
!defined(value) || !isNumber(+value)) {
return;
}
value = +value;
const targetSeries = chart.series[linkedValueTo.seriesIndex ?? 0], targetPoint = targetSeries?.points[linkedValueTo.pointIndex ?? 0];
if (targetSeries) {
if (targetPoint) {
targetPoint.update({
y: value
});
return;
}
targetSeries.addPoint({
y: value
});
return;
}
chart.addSeries({
data: [{
y: value
}]
});
}
/**
* Handles updating elements via options
*
* @internal
*/
updateElements() {
const { style, subtitle } = this.options;
this.setValue();
AST.setElementHTML(this.subtitle, this.getSubtitle());
if (style) {
css(this.element, style);
}
if (typeof subtitle === 'object') {
if (subtitle.style) {
css(this.subtitle, subtitle.style);
}
this.subtitle.className = this.getSubtitleClassName();
}
if (this.chartContainer) {
this.chartContainer.style.flex =
this.options.chartOptions ? '1' : '0';
}
if (this.chart) {
this.chart.reflow();
}
this.value.style.color = this.getValueColor();
}
/**
* Gets KPI subtitle text.
*
* @returns
* The subtitle's text.
*
* @internal
*/
getSubtitle() {
const { subtitle, value } = this.options;
if (typeof subtitle === 'string') {
return subtitle;
}
if (subtitle) {
if (isNumber(this.prevValue) && isNumber(value)) {
const diff = value - this.prevValue;
let prefix = '';
if (diff > 0) {
prefix = '<span style="color:green">▲</span> +';
}
else if (diff < 0) {
prefix = '<span style="color:red">▼</span> ';
}
else {
return this.subtitle.innerHTML;
}
if (subtitle.type === 'diff') {
return prefix + diff.toLocaleString();
}
if (subtitle.type === 'diffpercent') {
return prefix + format('{v:,.2f}%', {
v: diff / this.prevValue * 100
});
}
}
return subtitle.text || '';
}
return '';
}
/**
* Gets CSS class name of the KPI subtitle.
*
* @returns
* The name of class.
*
* @internal
*/
getSubtitleClassName() {
const { subtitle } = this.options;
return `${Component.defaultOptions.className}-subtitle` +
((typeof subtitle === 'object' && subtitle.className) || '');
}
/**
* Applies title's color according to the threshold.
*
* @returns
* Hex of color.
*
* @internal
*/
getValueColor() {
const { threshold, thresholdColors, value } = this.options;
if (thresholdColors && threshold && isNumber(value)) {
if (isArray(threshold)) {
for (let i = threshold.length - 1; i >= 0; i--) {
if (value >= threshold[i]) {
if (i + 1 < thresholdColors.length) {
return thresholdColors[i + 1];
}
return thresholdColors[thresholdColors.length - 1];
}
}
}
else if (value >= threshold) {
return thresholdColors[1];
}
return thresholdColors[0];
}
return '';
}
getOptionsOnDrop(sidebar) {
const connectorsIds = sidebar.editMode.board.dataPool.getConnectorIds();
let options = {
cell: '',
type: 'KPI'
};
if (connectorsIds.length) {
options = {
...options,
connector: {
id: connectorsIds[0]
}
};
}
return options;
}
/**
* Get the KPI component's options.
* @returns
* KPI component's options.
*
* @internal
*
*/
getOptions() {
return {
...diffObjects(this.options, KPIComponent.defaultOptions),
type: 'KPI'
};
}
}
/**
* Default options of the KPI component.
*/
KPIComponent.defaultOptions = merge(Component.defaultOptions, KPIComponentDefaults);
/**
* Predefined sync config for the KPI component.
*/
KPIComponent.predefinedSyncConfig = KPISyncs;
/**
* Default options of the KPI component.
*
* @default {
chart: {
type: 'spline',
zooming: {
mouseWheel: {
enabled: false
}
}
},
title: {
text: void 0
},
xAxis: {
visible: false
},
yAxis: {
visible: false,
title: {
text: null
}
},
legend: {
enabled: false
},
credits: {
enabled: false
},
tooltip: {
outside: true
},
plotOptions: {
series: {
marker: {
enabled: false
}
}
}
}
*/
KPIComponent.defaultChartOptions = {
chart: {
type: 'spline',
zooming: {
mouseWheel: {
enabled: false
}
}
},
title: {
text: void 0
},
xAxis: {
visible: false
},
yAxis: {
visible: false,
title: {
text: null
}
},
legend: {
enabled: false
},
credits: {
enabled: false
},
tooltip: {
outside: true
},
plotOptions: {
series: {
marker: {
enabled: false
}
}
}
};
/**
* The formula option's default formula functions map.
*/
KPIComponent.formulaFunctions = {
SUM,
AVERAGE,
MEDIAN,
MAX,
MIN,
COUNT,
PRODUCT
};
/* *
*
* Default Export
*
* */
export default KPIComponent;