@highcharts/dashboards
Version:
Highcharts Dashboards framework
562 lines (561 loc) • 19.3 kB
JavaScript
/* *
*
* (c) 2009-2025 Highsoft AS
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* Authors:
* - Gøran Slettemark
* - Wojciech Chmiel
* - Sebastian Bochan
* - Sophie Bremer
*
* */
'use strict';
import Component from '../Component.js';
import DataConverter from '../../../Data/Converters/DataConverter.js';
import DataTable from '../../../Data/DataTable.js';
import Globals from '../../Globals.js';
import HighchartsSyncs from './HighchartsSyncs/HighchartsSyncs.js';
import HighchartsComponentDefaults from './HighchartsComponentDefaults.js';
import DU from '../../Utilities.js';
import U from '../../../Core/Utilities.js';
const { createElement, diffObjects, isString, merge, splat } = U;
const { deepClone } = DU;
/* *
*
* Class
*
* */
/**
*
* Class that represents a Highcharts component.
*
*/
class HighchartsComponent extends Component {
/* *
*
* Constructor
*
* */
/**
* Creates a Highcharts component in the cell.
*
* @param options
* The options for the component.
*/
constructor(cell, options, board) {
options = merge(HighchartsComponent.defaultOptions, options);
super(cell, options, board);
/**
* An object of series IDs and their connector handlers.
*/
this.seriesFromConnector = {};
this.options = options;
this.chartConstructor = this.options.chartConstructor || 'chart';
this.type = 'Highcharts';
this.chartContainer = createElement('figure', void 0, void 0, this.contentElement, true);
this.setOptions();
this.chartOptions = merge((this.options.chartOptions ||
{ chart: {} }), {
tooltip: {} // Temporary fix for #18876
});
for (const connectorHandler of this.connectorHandlers) {
const connector = connectorHandler.connector;
if (connector) {
connector.on('afterLoad', (e) => {
const eventTables = e.tables;
let eventTable;
if (this.dataTableKey) {
eventTable = eventTables[this.dataTableKey];
}
else {
eventTable = Object.values(eventTables)[0];
}
const table = connector.getTable(this.dataTableKey);
table.setColumns(eventTable.getColumns());
});
}
}
this.innerResizeTimeouts = [];
}
onTableChanged() {
this.updateSeries();
}
/* *
*
* Functions
*
* */
/** @private */
async load() {
this.emit({ type: 'load' });
await super.load();
this.emit({ type: 'afterLoad' });
return this;
}
render() {
const hcComponent = this;
super.render();
hcComponent.chart = hcComponent.getChart();
hcComponent.updateSeries();
hcComponent.emit({ type: 'afterRender' });
hcComponent.setupConnectorUpdate();
this.sync.start();
return this;
}
resize(width, height) {
this.resizeDynamicContent(width, height);
while (this.innerResizeTimeouts.length) {
const timeoutID = this.innerResizeTimeouts.pop();
if (timeoutID) {
clearTimeout(timeoutID);
}
}
this.innerResizeTimeouts.push(setTimeout(() => {
if (this.chart && this.chart.container) {
const heightOffset = this.contentElement.offsetHeight -
this.chart?.container.offsetHeight;
this.chart.setSize(null, (Math.abs(heightOffset) > 1) ?
this.contentElement.offsetHeight : null, false);
}
}, 33));
return this;
}
/**
* Adds call update value in store, when chart's point is updated.
*
* @private
* */
setupConnectorUpdate() {
const { connectorHandlers, chart } = this;
if (!chart || !this.options.allowConnectorUpdate) {
return;
}
const seriesLength = chart.series.length;
for (let i = 0, iEnd = connectorHandlers.length; i < iEnd; i++) {
const connectorHandler = connectorHandlers[i];
for (let j = 0; j < seriesLength; j++) {
const series = chart.series[j];
series.update({
point: {
events: {
update: (e) => {
this.onChartUpdate(e.target, connectorHandler);
}
}
}
}, false);
}
}
}
/**
* Update the store, when the point is being dragged.
* @param point Dragged point.
* @param connectorHandler Connector handler with data to update.
*/
onChartUpdate(point, connectorHandler) {
const table = connectorHandler.connector?.getTable(this.dataTableKey);
const columnAssignment = connectorHandler.columnAssignment;
const seriesId = point.series.options.id;
const converter = new DataConverter();
const valueToSet = converter.asNumber(point.y);
if (!table) {
return;
}
let columnName;
if (columnAssignment && seriesId) {
const data = columnAssignment.find((s) => s.seriesId === seriesId)?.data;
if (isString(data)) {
columnName = data;
}
else if (Array.isArray(data)) {
columnName = data[1];
}
else if (data) {
columnName = data.y ?? data.value;
}
}
if (!columnName) {
columnName = seriesId ?? point.series.name;
}
table.setCell(columnName, point.index, valueToSet);
}
/**
* Internal method for handling option updates.
*
* @internal
*/
setOptions() {
if (this.options.chartClassName) {
this.chartContainer.classList.value =
HighchartsComponentDefaults.className + ' ' +
this.options.chartClassName;
}
if (this.options.chartID) {
this.chartContainer.id = this.options.chartID;
}
}
/**
* Handles updating via options.
* @param options
* The options to apply.
*
*/
async update(options, shouldRerender = true) {
await super.update(options, false);
this.setOptions();
if (this.options.chartConstructor !== this.chartConstructor) {
this.chartConstructor = this.options.chartConstructor || 'chart';
this.chartOptions = this.options.chartOptions || {};
this.chart?.destroy();
delete this.chart;
}
else {
this.chart?.update(merge(this.options.chartOptions) || {});
}
this.emit({ type: 'afterUpdate' });
shouldRerender && this.render();
}
/**
* Updates chart's series when the data table is changed.
* @private
*/
updateSeries() {
const { chart } = this;
const connectorHandlers = this.connectorHandlers;
if (!chart) {
return;
}
const newSeriesIds = [];
for (const connectorHandler of connectorHandlers) {
const options = connectorHandler.options;
let columnAssignment = options.columnAssignment;
// Set the new data table based on the data table key.
const connector = connectorHandler.connector;
const dataTableKey = this.dataTableKey;
if (connector && dataTableKey) {
connectorHandler.setTable(connector.dataTables[dataTableKey]);
}
if (!columnAssignment && connectorHandler.presentationTable) {
columnAssignment = this.getDefaultColumnAssignment(connectorHandler.presentationTable.getColumnNames(), connectorHandler.presentationTable);
}
if (columnAssignment) {
connectorHandler.columnAssignment = columnAssignment;
for (const { seriesId } of columnAssignment) {
if (seriesId) {
newSeriesIds.push(seriesId);
}
}
}
}
const seriesArray = Object.keys(this.seriesFromConnector);
// Remove series that were added in the previous update and are not
// present in the new columnAssignment.
for (let i = 0, iEnd = seriesArray.length; i < iEnd; ++i) {
const oldSeriesId = seriesArray[i];
if (newSeriesIds.some((newSeriesId) => newSeriesId === oldSeriesId)) {
continue;
}
const series = chart.get(oldSeriesId);
if (series) {
series.destroy();
}
}
this.seriesFromConnector = {};
for (const connectorHandler of connectorHandlers) {
this.updateSeriesFromConnector(connectorHandler);
}
chart.redraw();
}
/**
* Updates the series based on the connector from each connector handler.
* @param connectorHandler The connector handler.
* @private
*/
updateSeriesFromConnector(connectorHandler) {
const chart = this.chart;
if (!connectorHandler.connector ||
!chart ||
!connectorHandler.presentationTable) {
return;
}
const table = connectorHandler.presentationTable.modified;
const modifierOptions = connectorHandler.presentationTable.getModifier()?.options;
const columnAssignment = connectorHandler.columnAssignment ?? [];
// Create the series or update the existing ones.
for (let i = 0, iEnd = columnAssignment.length; i < iEnd; ++i) {
const assignment = columnAssignment[i];
const dataStructure = assignment.data;
const series = chart.get(assignment.seriesId);
const seriesOptions = {};
// Prevent dragging on series, which were created out of a
// columns which are created by MathModifier.
const adjustDraggableOptions = (compare) => {
if (modifierOptions?.type === 'Math' &&
modifierOptions
.columnFormulas?.some((formula) => compare(formula.column))) {
seriesOptions.dragDrop = {
draggableY: false
};
}
};
// Set the series data based on the column assignment data structure
// type.
if (isString(dataStructure)) {
const column = table.getColumn(dataStructure);
if (column) {
seriesOptions.data = column.slice();
}
adjustDraggableOptions((columnName) => (columnName === dataStructure));
}
else if (Array.isArray(dataStructure)) {
const seriesTable = new DataTable({
columns: table.getColumns(dataStructure)
});
seriesOptions.data = seriesTable.getRows();
adjustDraggableOptions((columnName) => (dataStructure.some((name) => name === columnName)));
}
else {
const keys = Object.keys(dataStructure);
const columnNames = [];
for (let j = 0, jEnd = keys.length; j < jEnd; ++j) {
columnNames.push(dataStructure[keys[j]]);
}
const seriesTable = new DataTable({
columns: table.getColumns(columnNames)
});
seriesOptions.keys = keys;
seriesOptions.data = seriesTable.getRows();
adjustDraggableOptions((columnName) => (columnNames.some((name) => name === columnName)));
}
if (!series) {
chart.addSeries({
name: assignment.seriesId,
id: assignment.seriesId,
...seriesOptions
}, false);
}
else {
series.update(seriesOptions, false);
}
this.seriesFromConnector[assignment.seriesId] = connectorHandler;
}
}
/**
* Destroy chart and create a new one.
*
* @returns
* The chart.
*
* @private
*
*/
getChart() {
return this.chart || this.createChart();
}
/**
* Destroys the highcharts component.
*/
destroy() {
// Cleanup references in the global Highcharts scope
this.chart?.destroy();
super.destroy();
}
/**
* Creates default mapping when columnAssignment is not declared.
* @param { Array<string>} columnNames all columns returned from dataTable.
*
* @returns
* The record of mapping
*
* @private
*
*/
getDefaultColumnAssignment(columnNames = [], presentationTable) {
const result = [];
const firstColumn = presentationTable.getColumn(columnNames[0]);
if (firstColumn && isString(firstColumn[0])) {
for (let i = 1, iEnd = columnNames.length; i < iEnd; ++i) {
result.push({
seriesId: columnNames[i],
data: [columnNames[0], columnNames[i]]
});
}
return result;
}
for (let i = 0, iEnd = columnNames.length; i < iEnd; ++i) {
result.push({
seriesId: columnNames[i],
data: columnNames[i]
});
}
return result;
}
/**
* Creates chart.
*
* @returns
* The chart.
*
* @private
*
*/
createChart() {
const charter = HighchartsComponent.charter || Globals.win.Highcharts;
if (!this.chartConstructor) {
this.chartConstructor = 'chart';
}
const Factory = charter[this.chartConstructor];
if (Factory) {
try {
if (this.chartConstructor === 'chart') {
return charter.Chart.chart(this.chartContainer, this.chartOptions);
}
return new Factory(this.chartContainer, this.chartOptions);
}
catch (e) {
throw new Error(`The Highcharts component in cell '${this.cell.id}' is misconfigured. \n____________\n${e}`);
}
}
if (typeof charter.chart !== 'function') {
throw new Error('Chart constructor not found');
}
return this.chart;
}
/**
* Registers events from the chart options to the callback register.
*
* @private
*/
registerChartEvents() {
if (this.chart && this.chart.options) {
const options = this.chart.options;
const allEvents = [
'chart',
'series',
'yAxis',
'xAxis',
'colorAxis',
'annotations',
'navigation'
].map((optionKey) => {
let seriesOrAxisOptions = options[optionKey] || {};
if (!Array.isArray(seriesOrAxisOptions) &&
seriesOrAxisOptions.events) {
seriesOrAxisOptions = [seriesOrAxisOptions];
}
if (seriesOrAxisOptions &&
typeof seriesOrAxisOptions === 'object' &&
Array.isArray(seriesOrAxisOptions)) {
return seriesOrAxisOptions.reduce((acc, seriesOrAxis, i) => {
if (seriesOrAxis && seriesOrAxis.events) {
acc[seriesOrAxis.id || `${optionKey}-${i}`] = seriesOrAxis.events;
}
return acc;
}, {}) || {};
}
return {};
});
allEvents.forEach((options) => {
Object.keys(options).forEach((key) => {
const events = options[key];
Object.keys(events).forEach((callbackKey) => {
this.callbackRegistry.addCallback(`${key}-${callbackKey}`, {
type: 'seriesEvent',
func: events[callbackKey]
});
});
});
});
}
}
getOptionsOnDrop(sidebar) {
const connectorsIds = sidebar.editMode.board.dataPool.getConnectorIds();
let options = {
cell: '',
type: 'Highcharts',
chartOptions: {
chart: {
animation: false,
type: 'column',
zooming: {}
}
}
};
if (connectorsIds.length) {
options = {
...options,
connector: {
id: connectorsIds[0]
}
};
}
return options;
}
/**
* Get the HighchartsComponent component's options.
* @returns
* HighchartsComponent component's options.
*
* @internal
*
*/
getOptions() {
return {
...diffObjects(this.options, HighchartsComponent.defaultOptions),
type: 'Highcharts'
};
}
/**
* Retrieves editable options for the chart.
*
* @returns
* The editable options for the chart and its values.
*/
getEditableOptions() {
const component = this;
const componentOptions = component.options;
const chart = component.chart;
const chartOptions = chart && chart.options;
const chartType = chartOptions?.chart?.type || 'line';
return deepClone(merge({
chartOptions
}, {
chartOptions: {
yAxis: splat(chart && chart.yAxis[0].options),
xAxis: splat(chart && chart.xAxis[0].options),
plotOptions: {
series: ((chartOptions && chartOptions.plotOptions) ||
{})[chartType]
}
}
}, componentOptions), ['dataTable', 'points', 'series', 'data', 'editableOptions']);
}
getEditableOptionValue(propertyPath) {
const component = this;
if (!propertyPath) {
return;
}
if (propertyPath.length === 1 && propertyPath[0] === 'chartOptions') {
return JSON.stringify(component.options.chartOptions, null, 2);
}
return super.getEditableOptionValue.call(this, propertyPath);
}
}
/**
* Predefined sync config for Highcharts component.
*/
HighchartsComponent.predefinedSyncConfig = HighchartsSyncs;
/**
* Default options of the Highcharts component.
*/
HighchartsComponent.defaultOptions = merge(Component.defaultOptions, HighchartsComponentDefaults);
/* *
*
* Default Export
*
* */
export default HighchartsComponent;