@eclipse-scout/chart
Version:
Eclipse Scout chart
1,232 lines (1,099 loc) • 124 kB
text/typescript
/*
* Copyright (c) 2010, 2025 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
import {AbstractChartRenderer, CartesianChartScale, Chart, ChartAxis, ChartColorMode, ChartConfig, ChartData, chartJsDateAdapter, ChartType, ClickObject, NumberFormatter, RadialChartScale} from '../index';
import {
_adapters as chartJsAdapters, ActiveElement, ArcElement, BarElement, BubbleDataPoint, CartesianScaleOptions, CategoryScale, Chart as ChartJs, ChartArea, ChartConfiguration, ChartDataset, ChartEvent, ChartType as ChartJsType, Color,
DefaultDataPoint, FontSpec, LegendElement, LegendItem, LegendOptions, LinearScaleOptions, Point as ChartJsPoint, PointElement, PointHoverOptions, RadialLinearScaleOptions, Scale, ScatterDataPoint, Scriptable, ScriptableContext,
TooltipCallbacks, TooltipItem, TooltipLabelStyle, TooltipModel, TooltipOptions
} from 'chart.js';
import 'chart.js/auto'; // Import from auto to register charts
import {aria, arrays, colorSchemes, graphics, numbers, objects, Point, scout, SomeRequired, strings, styles, Tooltip, tooltips} from '@eclipse-scout/core';
import ChartDataLabels, {Context} from 'chartjs-plugin-datalabels';
import $ from 'jquery';
ChartJs.register(ChartDataLabels);
$.extend(true, ChartJs.defaults, {
maintainAspectRatio: false,
elements: {
line: {
borderWidth: 2
},
point: {
radius: 0,
hitRadius: 10,
hoverRadius: 7,
hoverBorderWidth: 2
},
arc: {
borderWidth: 1
},
bar: {
borderWidth: 1,
borderSkipped: ''
}
},
plugins: {
legend: {
labels: {
usePointStyle: true,
boxHeight: 7
}
}
}
});
$.extend(true, ChartJs.overrides, {
line: {
elements: {
point: {
borderWidth: 2
}
}
},
scatter: {
elements: {
point: {
radius: 3
}
}
},
bubble: {
layout: {
// Enabled auto padding would cause Chart.js to automatically add padding, in the size of the largest bubble
// (defined by 'sizeOfLargestBubble'), around the chart. This wastes spaces and is not necessary, as we adjust
// the axes already with the bubble size in mind.
autoPadding: false
}
}
});
let chartJsGlobalsInitialized = false;
const PHI = (1 + Math.sqrt(5)) / 2; // golden ratio
export class ChartJsRenderer extends AbstractChartRenderer {
static ARROW_LEFT_RIGHT = '\u2194';
static ARROW_UP_DOWN = '\u2195';
chartJs: ChartJsChart;
onlyIntegers: boolean;
maxXAxesTicksHeight: number;
numSupportedColors: number;
colorSchemeCssClass: string;
minRadialChartDatalabelSpace: number;
resetDatasetAfterHover: boolean;
legendHoverDatasets: number[];
removing: boolean;
$canvas: JQuery<HTMLCanvasElement>;
protected _labelFormatter: LabelFormatter;
protected _xLabelFormatter: LabelFormatter;
protected _yLabelFormatter: LabelFormatter;
protected _xAxisFitter: AxisFitter;
protected _yAxisFitter: AxisFitter;
protected _radialChartDatalabelsDisplayHandler: DatalabelsDisplayHandler;
protected _radialChartDatalabelsFormatter: RadialChartDatalabelsFormatter;
protected _datalabelsFormatter: DatalabelsFormatter;
protected _datalabelBackgroundColorHandler: DatalabelBackgroundColorHandler;
protected _legendLabelGenerator: LegendLabelGenerator;
protected _clickHandler: ChartEventHandler;
protected _hoverHandler: ChartEventHandler;
protected _pointerHoverHandler: ChartEventHandler;
protected _legendClickHandler: LegendEventHandler;
protected _legendHoverHandler: LegendEventHandler;
protected _legendPointerHoverHandler: LegendEventHandler;
protected _legendLeaveHandler: LegendEventHandler;
protected _legendPointerLeaveHandler: LegendEventHandler;
protected _resizeHandler: ResizeHandler;
protected _tooltipTitleGenerator: TooltipTitleGenerator;
protected _tooltipItemsGenerator: TooltipItemsGenerator;
protected _tooltipLabelGenerator: TooltipLabelGenerator;
protected _tooltipLabelValueGenerator: TooltipLabelValueGenerator;
protected _tooltipLabelColorGenerator: TooltipLabelColorGenerator;
protected _tooltipRenderer: TooltipRenderer;
protected _tooltip: Tooltip;
protected _tooltipTimeoutId: number;
protected _updatingDatalabels: boolean;
protected _hoveringClickableElement: boolean;
constructor(chart: Chart) {
super(chart);
this.chartJs = null;
this.onlyIntegers = true;
this.maxXAxesTicksHeight = 75;
this.numSupportedColors = 6;
this.colorSchemeCssClass = '';
this.minRadialChartDatalabelSpace = 25;
this._labelFormatter = this._createLabelFormatter(this._formatLabel);
this._xLabelFormatter = this._createLabelFormatter(this._formatXLabel);
this._yLabelFormatter = this._createLabelFormatter(this._formatYLabel);
this._xAxisFitter = this._fitXAxis.bind(this);
this._yAxisFitter = this._fitYAxis.bind(this);
this._radialChartDatalabelsDisplayHandler = this._displayDatalabelsOnRadialChart.bind(this);
this._radialChartDatalabelsFormatter = this._formatDatalabelsOnRadialChart.bind(this);
this._datalabelsFormatter = this._formatDatalabels.bind(this);
this._datalabelBackgroundColorHandler = this._getBackgroundColorOfDataset.bind(this);
this._legendLabelGenerator = this._generateLegendLabels.bind(this);
this.resetDatasetAfterHover = false;
this._clickHandler = this._onClick.bind(this);
this._hoverHandler = this._onHover.bind(this);
this._pointerHoverHandler = this._onHoverPointer.bind(this);
this.legendHoverDatasets = [];
this._legendClickHandler = this._onLegendClick.bind(this);
this._legendHoverHandler = this._onLegendHover.bind(this);
this._legendPointerHoverHandler = this._onLegendHoverPointer.bind(this);
this._legendLeaveHandler = this._onLegendLeave.bind(this);
this._legendPointerLeaveHandler = this._onLegendLeavePointer.bind(this);
this._resizeHandler = this._onResize.bind(this);
this._tooltipTitleGenerator = this._generateTooltipTitle.bind(this);
this._tooltipItemsGenerator = this._generateTooltipItems.bind(this);
this._tooltipLabelGenerator = this._generateTooltipLabel.bind(this);
this._tooltipLabelValueGenerator = this._generateTooltipLabelValue.bind(this);
this._tooltipLabelColorGenerator = this._generateTooltipLabelColor.bind(this);
this._tooltipRenderer = this._renderTooltip.bind(this);
this._tooltip = null;
this._tooltipTimeoutId = null;
}
protected override _validateChartData(): boolean {
let chartDataValid = true;
let chartData = this.chart && this.chart.data;
if (!chartData || !chartData.chartValueGroups || chartData.chartValueGroups.length === 0 || !chartData.axes) {
chartDataValid = false;
}
if (chartDataValid && scout.isOneOf(this.chart.config.type, Chart.Type.POLAR_AREA, Chart.Type.RADAR)) {
// check lengths
let i, length = 0;
for (i = 0; i < chartData.chartValueGroups.length; i++) {
let chartValueGroup = chartData.chartValueGroups[i];
if (!chartValueGroup.values) {
chartDataValid = false;
}
// Length of all "values" arrays have to be equal
if (i === 0) {
length = chartValueGroup.values.length;
} else {
if (chartValueGroup.values.length !== length) {
chartDataValid = false;
}
}
}
for (i = 0; i < chartData.axes.length; i++) {
if (chartData.axes[i].length !== length) {
chartDataValid = false;
}
}
}
if (chartDataValid) {
return true;
}
let chartConfigDataValid = true;
let config = this.chart && this.chart.config;
if (!config || !config.data || !config.data.datasets || config.data.datasets.length === 0) {
chartConfigDataValid = false;
}
if (chartConfigDataValid && scout.isOneOf(config.type, Chart.Type.POLAR_AREA, Chart.Type.RADAR)) {
// check lengths
let i, length = 0;
for (i = 0; i < config.data.datasets.length; i++) {
let dataset = config.data.datasets[i];
if (!dataset.data) {
chartConfigDataValid = false;
}
// Length of all "data" arrays have to be equal
if (i === 0) {
length = dataset.data.length;
} else {
if (dataset.data.length !== length) {
chartConfigDataValid = false;
}
}
}
}
return chartConfigDataValid;
}
protected override _render() {
if (!this.$canvas) {
this.$canvas = this.chart.$container.appendElement('<canvas>') as JQuery<HTMLCanvasElement>;
this.$canvas.on('click', this._onCanvasClick.bind(this));
aria.hidden(this.$canvas, true); // aria not supported yet
}
this.firstOpaqueBackgroundColor = styles.getFirstOpaqueBackgroundColor(this.$canvas);
if (!chartJsGlobalsInitialized) {
ChartJs.defaults.font.family = this.$canvas.css('font-family');
chartJsAdapters._date.override(chartJsDateAdapter.getAdapter(this.chart.session));
chartJsGlobalsInitialized = true;
}
let config = $.extend(true, {}, this.chart.config);
this._adjustConfig(config);
this._renderChart(config);
}
protected _renderChart(config: ChartConfig) {
if (this.chartJs) {
this.chartJs.destroy();
}
config = $.extend(true, {}, config, {
options: {
animation: {}
}
}, config);
config.options.animation.duration = this.animationDuration;
this.chartJs = new ChartJs(this.$canvas[0].getContext('2d'), config as ChartConfiguration) as ChartJsChart;
this._adjustSize(this.chartJs.config, this.chartJs.chartArea, {isDatasetVisible: i => this.chartJs.getDatasetMeta(i).visible});
this.refresh();
}
protected override _updateData() {
if (!this.chartJs) {
return;
}
let config = $.extend(true, {}, this.chart.config);
this._adjustConfig(config);
let targetData = this.chartJs.config.data,
sourceData = config.data;
// Transfer property from source object to target object:
// 1. If the property is not set on the target object, copy it from source.
// 2. If the property is not set on the source object, set it to undefined if setToUndefined = true. Otherwise, empty the array if it is an array property or set it to undefined.
// 3. If the property is not an array on the source or the target object, copy the property from the source to the target object.
// 4. If the property is an array on both objects, do not update the array, but transfer the elements (update elements directly, use pop(), push() or splice() if one array is longer than the other).
let transferProperty = (source: object, target: object, property: string, setToUndefined?) => {
if (!source || !target || !property) {
return;
}
// 1. Property not set on target
if (!target[property]) {
let src = source[property];
if (src || setToUndefined) {
target[property] = src;
}
return;
}
// 2. Property not set on source
if (!source[property]) {
if (setToUndefined) {
// Set to undefined if setToUndefined = true
target[property] = undefined;
return;
}
// Empty array
if (Array.isArray(target[property])) {
target[property].splice(0, target[property].length);
return;
}
// Otherwise set to undefined
target[property] = undefined;
return;
}
// 3. Property is not an array on the source or the target object
if (!Array.isArray(source[property]) || !Array.isArray(target[property])) {
target[property] = source[property];
return;
}
// 4. Property is an array on the source and the target object
for (let i = 0; i < Math.min(source[property].length, target[property].length); i++) {
// Update elements directly
target[property][i] = source[property][i];
}
let targetLength = target[property].length,
sourceLength = source[property].length;
if (targetLength > sourceLength) {
// Target array is longer than source array
target[property].splice(sourceLength, targetLength - sourceLength);
} else if (targetLength < sourceLength) {
// Source array is longer than target array
target[property].push(...source[property].splice(targetLength));
}
};
let findDataset = (datasets: ChartDataset[], datasetId) => arrays.find(datasets, dataset => dataset.datasetId === datasetId);
let findDatasetIndex = (datasets: ChartDataset[], datasetId) => arrays.findIndex(datasets, dataset => dataset.datasetId === datasetId);
if (targetData && sourceData) {
// Transfer properties from source to target, instead of overwriting the whole data object.
// This needs to be done to have a smooth animation from the old to the new state and not a complete rebuild of the chart.
transferProperty(sourceData, targetData, 'labels');
if (!targetData.datasets) {
targetData.datasets = [];
}
if (!sourceData.datasets) {
sourceData.datasets = [];
}
// if all datasets have no id set, add artificial dataset ids
if (sourceData.datasets.every(dataset => objects.isNullOrUndefined(dataset.datasetId))) {
sourceData.datasets.forEach((dataset, idx) => {
dataset.datasetId = '' + idx;
});
targetData.datasets.forEach((dataset, idx) => {
dataset.datasetId = '' + idx;
});
}
// update existing datasets
// Important: Update existing datasets first, before removing obsolete datasets
// (the dataset object has listeners from Chart.js, which do not work well on a partially updated chart (updated datasets, but not yet updated chart)
targetData.datasets.forEach(targetDataset => {
let sourceDataset = findDataset(sourceData.datasets, targetDataset.datasetId);
if (sourceDataset) {
targetDataset.label = sourceDataset.label;
targetDataset.type = sourceDataset.type;
transferProperty(sourceDataset, targetDataset, 'data');
transferProperty(sourceDataset, targetDataset, 'backgroundColor', true);
transferProperty(sourceDataset, targetDataset, 'borderColor', true);
transferProperty(sourceDataset, targetDataset, 'hoverBackgroundColor', true);
transferProperty(sourceDataset, targetDataset, 'hoverBorderColor', true);
transferProperty(sourceDataset, targetDataset, 'legendColor', true);
transferProperty(sourceDataset, targetDataset, 'pointBackgroundColor', true);
transferProperty(sourceDataset, targetDataset, 'pointHoverBackgroundColor', true);
transferProperty(sourceDataset, targetDataset, 'pointBorderColor', true);
transferProperty(sourceDataset, targetDataset, 'pointHoverBorderColor', true);
transferProperty(sourceDataset, targetDataset, 'uncheckedBackgroundColor', true);
transferProperty(sourceDataset, targetDataset, 'uncheckedHoverBackgroundColor', true);
transferProperty(sourceDataset, targetDataset, 'checkedBackgroundColor', true);
transferProperty(sourceDataset, targetDataset, 'checkedHoverBackgroundColor', true);
transferProperty(sourceDataset, targetDataset, 'uncheckedPointBackgroundColor', true);
transferProperty(sourceDataset, targetDataset, 'uncheckedPointHoverBackgroundColor', true);
transferProperty(sourceDataset, targetDataset, 'checkedPointBackgroundColor', true);
transferProperty(sourceDataset, targetDataset, 'checkedPointHoverBackgroundColor', true);
transferProperty(sourceDataset, targetDataset, 'lineTension', true);
transferProperty(sourceDataset, targetDataset, 'pointRadius', true);
transferProperty(sourceDataset, targetDataset, 'uncheckedPointRadius', true);
transferProperty(sourceDataset, targetDataset, 'checkedPointRadius', true);
this._adjustDatasetBorderWidths(targetDataset);
}
});
// remove deleted datasets, loop backwards to not compromise the loop when modifying the array
// datasets without an id get deleted anyway (replaced in every update, because a correct identification is not possible)
for (let i = targetData.datasets.length - 1; i >= 0; i--) {
let datasetId = targetData.datasets[i].datasetId;
let deleted = objects.isNullOrUndefined(datasetId) || findDatasetIndex(sourceData.datasets, datasetId) === -1;
if (deleted) {
targetData.datasets.splice(i, 1);
}
}
// sort existing, updated datasets
targetData.datasets.sort((a, b) => {
return findDatasetIndex(sourceData.datasets, a.datasetId) - findDatasetIndex(sourceData.datasets, b.datasetId);
});
// add all new datasets
sourceData.datasets.forEach((sourceDataset, idx) => {
let targetDataset = targetData.datasets[idx];
// exclude datasets without an id here, to ensure that multiple datasets without an id do not overwrite each other
if (targetDataset && targetDataset.datasetId && sourceDataset.datasetId === targetDataset.datasetId) {
return;
}
targetData.datasets.splice(idx, 0, sourceDataset);
});
} else {
this.chartJs.config.data = sourceData;
}
// update label maps for scales (the label maps, despite being part of the config, can be updated, without redrawing the whole chart)
transferProperty(config.options, this.chartJs.config.options, 'xLabelMap', true);
transferProperty(config.options, this.chartJs.config.options, 'yLabelMap', true);
$.extend(true, this.chartJs.config, {
options: {
animation: {
duration: this.animationDuration
}
}
});
let scales = this.chartJs.config.options.scales || {},
axes = [scales.x || {}, scales.y || {}, scales.yDiffType || {}, scales.r || {}];
axes.forEach((axis: LinearScaleOptions | RadialLinearScaleOptions) => {
(axis.ticks || {} as (LinearScaleOptions | RadialLinearScaleOptions)['ticks']).stepSize = undefined;
});
this.refresh();
this._adjustSize(this.chartJs.config, this.chartJs.chartArea, {isDatasetVisible: i => this.chartJs.getDatasetMeta(i).visible});
this.refresh();
}
override isDataUpdatable(): boolean {
return true;
}
override isDetachSupported(): boolean {
// chart.js removes the animation-listeners onProgress and onComplete on detach and does not add them again on attach
// these listeners are needed for the datalabels => this renderer does not support detach
return false;
}
override refresh() {
if (this.chartJs) {
this.chartJs.update();
} else {
super.refresh();
}
}
protected override _renderAnimationDuration() {
if (!this.chartJs) {
return;
}
$.extend(true, this.chartJs.config, {
options: {
animation: {
duration: this.animationDuration
}
}
});
this.refresh();
}
protected override _renderCheckedItems() {
if (this.chartJs && this._checkItems(this.chartJs.config)) {
this.refresh();
}
}
protected _checkItems(config: ChartConfig): boolean {
if (!config || !config.data) {
return false;
}
let transferArrayValues = (target, source, indices) => {
if (Array.isArray(target) && Array.isArray(source)) {
let changed = 0;
arrays.ensure(indices)
.filter(index => !isNaN(index) && index < Math.min(target.length, source.length))
.forEach(index => {
if (target[index] !== source[index]) {
target[index] = source[index];
changed++;
}
});
return changed;
}
return 0;
};
let changed = 0;
config.data.datasets.forEach((dataset: ChartDataset, datasetIndex) => {
let checkedIndices = this.chart.checkedItems.filter(item => item.datasetIndex === datasetIndex)
.map(item => item.dataIndex),
uncheckedIndices = arrays.init(dataset.data.length, null).map((elem, idx) => idx);
arrays.removeAll(uncheckedIndices, checkedIndices);
changed = changed +
// check
transferArrayValues(dataset.backgroundColor, dataset.checkedBackgroundColor, checkedIndices) +
transferArrayValues(dataset.hoverBackgroundColor, dataset.checkedHoverBackgroundColor, checkedIndices) +
transferArrayValues(dataset.pointBackgroundColor, dataset.checkedPointBackgroundColor, checkedIndices) +
transferArrayValues(dataset.pointHoverBackgroundColor, dataset.checkedPointHoverBackgroundColor, checkedIndices) +
transferArrayValues(dataset.pointRadius, dataset.checkedPointRadius, checkedIndices) +
// uncheck
transferArrayValues(dataset.backgroundColor, dataset.uncheckedBackgroundColor, uncheckedIndices) +
transferArrayValues(dataset.hoverBackgroundColor, dataset.uncheckedHoverBackgroundColor, uncheckedIndices) +
transferArrayValues(dataset.pointBackgroundColor, dataset.uncheckedPointBackgroundColor, uncheckedIndices) +
transferArrayValues(dataset.pointHoverBackgroundColor, dataset.uncheckedPointHoverBackgroundColor, uncheckedIndices) +
transferArrayValues(dataset.pointRadius, dataset.uncheckedPointRadius, uncheckedIndices);
this._adjustDatasetBorderWidths(dataset);
});
return 0 < changed;
}
stopAnimations() {
if (this.chartJs) {
this.chartJs.stop();
}
}
protected _adjustConfig(config: ChartConfig) {
if (!config || !config.type) {
return;
}
this._adjustType(config);
if (this.chart.data) {
this._computeDatasets(this.chart.data, config);
}
this._adjustData(config);
this._adjustLegend(config);
this._adjustTooltip(config);
this._adjustGrid(config);
this._adjustPlugins(config);
this._adjustColors(config);
this._adjustClickHandler(config);
this._adjustResizeHandler(config);
}
protected _adjustType(config: ChartConfig) {
if (config.type === Chart.Type.COMBO_BAR_LINE) {
config.type = Chart.Type.BAR;
let scaleLabelByTypeMap = (config.options || {}).scaleLabelByTypeMap;
if (scaleLabelByTypeMap) {
scaleLabelByTypeMap[Chart.Type.BAR] = scaleLabelByTypeMap[Chart.Type.COMBO_BAR_LINE];
}
} else if (this._isHorizontalBar(config)) {
config.type = Chart.Type.BAR;
config.options = $.extend(true, {}, config.options, {
indexAxis: 'y'
});
}
}
protected _computeDatasets(chartData: ChartData, config: ChartConfig) {
let labels = [],
datasets = [];
let setLabelMap = (identifier, labelMap) => {
if (!$.isEmptyObject(labelMap)) {
config.options[identifier] = labelMap;
}
};
(chartData.axes[0] || []).forEach(elem => labels.push(elem.label));
let isHorizontalBar = this._isHorizontalBar(config);
setLabelMap(isHorizontalBar ? 'yLabelMap' : 'xLabelMap', this._computeLabelMap(chartData.axes[0]));
setLabelMap(isHorizontalBar ? 'xLabelMap' : 'yLabelMap', this._computeLabelMap(chartData.axes[1]));
chartData.chartValueGroups.forEach(elem => datasets.push({
type: elem.type,
label: elem.groupName,
data: $.extend(true, [], elem.values)
}));
config.data = {
labels: labels,
datasets: datasets
};
}
protected _isHorizontalBar(config: ChartConfig): boolean {
return config && (config.type === Chart.Type.BAR_HORIZONTAL
|| (config.type === Chart.Type.BAR && config.options && config.options.indexAxis === 'y'));
}
protected _computeLabelMap(axis: ChartAxis[]): Record<number, string> {
let labelMap = {};
(axis || []).forEach((elem, idx) => {
labelMap[idx] = elem.label;
});
return labelMap;
}
protected _adjustData(config: ChartConfig) {
if (!config || !config.data || !config.type) {
return;
}
this._adjustBarBorderWidth(config);
this._adjustMaxSegments(config);
this._adjustBubbleRadii(config);
this._adjustOnlyIntegers(config);
}
protected _adjustBarBorderWidth(config: ChartConfig) {
if (!config || !config.data || !config.type || !scout.isOneOf(config.type, Chart.Type.BAR)) {
return;
}
config.data.datasets.forEach(dataset => {
if ((dataset.type || Chart.Type.BAR) === Chart.Type.BAR) {
dataset.borderWidth = dataset.borderWidth || 1;
dataset.hoverBorderWidth = dataset.hoverBorderWidth || 2;
this._adjustDatasetBorderWidths(dataset);
}
});
}
protected _adjustDatasetBorderWidths(dataset: ChartDataset) {
this._adjustDatasetBorderWidth(dataset);
this._adjustDatasetBorderWidth(dataset, true);
}
/**
* Sets the borderWidth to 0 if the backgroundColor and the borderColor are identical and backups the original value.
* This method is idempotent as it restores the original state first and then applies its logic.
*
* @param hover whether hoverBorderWidth, hoverBackgroundColor and hoverBorderColor should be considered instead of borderWidth, backgroundColor and borderColor
*/
protected _adjustDatasetBorderWidth(dataset: ChartDataset, hover?: boolean) {
if (!dataset) {
return;
}
let borderWidthIdentifier = hover ? 'hoverBorderWidth' : 'borderWidth',
borderWidthBackupIdentifier = hover ? 'hoverBorderWidthBackup' : 'borderWidthBackup',
backgroundColorIdentifier = hover ? 'hoverBackgroundColor' : 'backgroundColor',
borderColorIdentifier = hover ? 'hoverBorderColor' : 'borderColor';
// restore original state if there is a backup
if (dataset[borderWidthBackupIdentifier]) {
dataset[borderWidthIdentifier] = dataset[borderWidthBackupIdentifier];
delete dataset[borderWidthBackupIdentifier];
}
// do nothing if there is no borderWidth set on the dataset or the borderWidth is a function
if (!dataset[borderWidthIdentifier] || objects.isFunction(dataset[borderWidthIdentifier])) {
return;
}
let isBorderWidthArray = Array.isArray(dataset[borderWidthIdentifier]),
isBackgroundColorArray = Array.isArray(dataset[backgroundColorIdentifier]),
isBorderColorArray = Array.isArray(dataset[borderColorIdentifier]),
isArray = isBorderWidthArray || isBackgroundColorArray || isBorderColorArray;
// if none of the properties is an array, simply backup the borderWidth and set it to 0
if (!isArray && dataset[backgroundColorIdentifier] === dataset[borderColorIdentifier]) {
dataset[borderWidthBackupIdentifier] = dataset[borderWidthIdentifier];
dataset[borderWidthIdentifier] = 0;
return;
}
// at least one of the properties is an array, therefore the borderWidth needs to be an array from now on
let dataLength = (dataset.data || []).length;
if (!isBorderWidthArray) {
dataset[borderWidthIdentifier] = arrays.init(dataLength, dataset[borderWidthIdentifier]);
} else if (dataset[borderWidthIdentifier].length < dataLength) {
dataset[borderWidthIdentifier].push(...arrays.init(dataLength - dataset[borderWidthIdentifier].length, dataset[borderWidthIdentifier][0]));
}
let borderWidth = dataset[borderWidthIdentifier],
length = borderWidth.length,
borderWidthBackup = arrays.init(length, null);
for (let i = 0; i < length; i++) {
// it makes no difference if the backgroundColor/borderColor is not an array as a not-array-value is applied to every element by chart.js
let backgroundColor = isBackgroundColorArray ? dataset[backgroundColorIdentifier][i] : dataset[backgroundColorIdentifier],
borderColor = isBorderColorArray ? dataset[borderColorIdentifier][i] : dataset[borderColorIdentifier];
borderWidthBackup[i] = borderWidth[i];
if (backgroundColor === borderColor) {
borderWidth[i] = 0;
}
}
// only set the backup if at least one of the borderWidths changed
if (!arrays.equals(borderWidth, borderWidthBackup)) {
dataset[borderWidthBackupIdentifier] = borderWidthBackup;
}
}
protected _adjustMaxSegments(config: ChartConfig) {
if (!config || !config.data || !config.type || !scout.isOneOf(config.type, Chart.Type.PIE, Chart.Type.DOUGHNUT, Chart.Type.POLAR_AREA, Chart.Type.RADAR)) {
return;
}
let maxSegments = config.options.maxSegments;
if (!(maxSegments && config.data.datasets.length && maxSegments < config.data.datasets[0].data.length)) {
return;
}
config.data.datasets.forEach(elem => {
let newData = elem.data.slice(0, maxSegments);
newData[maxSegments - 1] = elem.data.slice(maxSegments - 1, elem.data.length).reduce((x: number, y: number) => {
return x + y;
}, 0);
elem.data = newData;
});
let newLabels = config.data.labels.slice(0, maxSegments);
newLabels[maxSegments - 1] = this.chart.session.text('ui.OtherValues');
config.data.labels = newLabels;
config.data.maxSegmentsExceeded = true;
}
protected _isMaxSegmentsExceeded(config: ChartConfig, index: number): boolean {
if (!scout.isOneOf(config.type, Chart.Type.PIE, Chart.Type.DOUGHNUT, Chart.Type.POLAR_AREA, Chart.Type.RADAR)) {
return false;
}
if (config.options.otherSegmentClickable) {
return false;
}
if (!config.data.maxSegmentsExceeded || !config.options.maxSegments) {
return false;
}
return config.options.maxSegments - 1 <= index;
}
/**
* Fill temporary variable z for every bubble, if not yet set, and set bubble radius temporary to 1.
* This allows the chart to render itself with correct dimensions and with no interfering from bubbles (very large bubbles make the chart grid itself smaller).
* Later on in {@link _adjustBubbleSizes}, the bubble sizes will be calculated relative to the chart dimensions and the configured min/max sizes.
*/
protected _adjustBubbleRadii(config: ChartConfig) {
if (!config || !config.data || !config.type || config.type !== Chart.Type.BUBBLE) {
return;
}
config.data.datasets.forEach(dataset => dataset.data.forEach((data: BubbleDataPoint) => {
if (!isNaN(data.r)) {
data.z = Math.pow(data.r, 2);
}
data.r = 1;
}));
}
protected _adjustOnlyIntegers(config: ChartConfig) {
this.onlyIntegers = true;
if (!config || !config.data || !config.type) {
return;
}
if (scout.isOneOf(config.type, Chart.Type.BUBBLE, Chart.Type.SCATTER)) {
this.onlyIntegers = config.data.datasets.every(dataset => dataset.data.every((data: ScatterDataPoint | BubbleDataPoint) => numbers.isInteger(data.x) && numbers.isInteger(data.y)));
} else {
this.onlyIntegers = config.data.datasets.every(dataset => dataset.data.every(data => numbers.isInteger(data)));
}
}
protected _adjustLegend(config: ChartConfig) {
if (!config || !config.type || !config.options) {
return;
}
config.options = $.extend(true, {}, config.options, {
plugins: {
legend: {
labels: {
generateLabels: this._legendLabelGenerator
}
}
}
});
if (!config.options.plugins.legend.pointsVisible) {
config.options.plugins.legend.labels.boxWidth = 0;
}
}
protected _adjustTooltip(config: ChartConfig) {
if (!config) {
return;
}
config.options = $.extend(true, {}, {
plugins: {
tooltip: {
callbacks: {
title: this._tooltipTitleGenerator,
items: this._tooltipItemsGenerator,
label: this._tooltipLabelGenerator,
labelValue: this._tooltipLabelValueGenerator,
labelColor: this._tooltipLabelColorGenerator
}
}
}
}, config.options);
let tooltip = config.options.plugins.tooltip;
if (!tooltip.enabled) {
return;
}
tooltip.enabled = false;
tooltip.external = this._tooltipRenderer;
}
protected _generateTooltipTitle(tooltipItems: TooltipItem<any>[]): string | string[] {
if (!tooltipItems || !tooltipItems.length) {
return '';
}
let tooltipItem = tooltipItems[0],
chart = tooltipItem.chart as ChartJsChart,
config = chart.config,
dataset = tooltipItem.dataset,
title = [];
if (scout.isOneOf(config.type, Chart.Type.BUBBLE)) {
let xAxis = config.options.scales.x,
yAxis = config.options.scales.y,
axisLabels = this._getAxisLabels(config);
// @ts-expect-error
let xTickLabel = xAxis.ticks.callback(dataset.data[tooltipItem.dataIndex].x, null, null) as string;
if (xTickLabel) {
title.push(this._createTooltipAttribute(axisLabels.x, strings.encode(xTickLabel), true));
}
// @ts-expect-error
let yTickLabel = yAxis.ticks.callback(dataset.data[tooltipItem.dataIndex].y, null, null) as string;
if (yTickLabel) {
title.push(this._createTooltipAttribute(axisLabels.y, strings.encode(yTickLabel), true));
}
} else if (scout.isOneOf(config.type, Chart.Type.SCATTER)) {
// nop, scatter has the title in the items table
} else {
let label = chart.data.labels[tooltipItem.dataIndex] as string;
title.push(this._createTooltipAttribute(config.options.reformatLabels ? this._formatLabel(label) : label, '', true));
}
return title;
}
protected _getAxisLabels(config: ChartConfig): { x: string; y: string } {
let xAxisLabel = config.options.scales.x.title.text,
yAxisLabel = config.options.scales.y.title.text;
xAxisLabel = this._resolveAxisLabel(xAxisLabel as string, ChartJsRenderer.ARROW_LEFT_RIGHT);
yAxisLabel = this._resolveAxisLabel(yAxisLabel as string, ' ' + ChartJsRenderer.ARROW_UP_DOWN + ' ');
return {x: xAxisLabel, y: yAxisLabel};
}
protected _resolveAxisLabel(axisLabel: string | (() => string), defaultLabel = ''): string {
if (objects.isFunction(axisLabel)) {
axisLabel = axisLabel();
axisLabel = objects.isString(axisLabel) ? axisLabel : '';
}
return axisLabel ? strings.encode(axisLabel) : defaultLabel;
}
protected _generateTooltipItems(tooltipItems: TooltipItem<any>[], tooltipLabel: TooltipLabelGenerator, tooltipLabelValue: TooltipLabelValueGenerator, tooltipColor: TooltipLabelColorGenerator): string {
if (!tooltipItems || !tooltipItems.length) {
return '';
}
let tooltipItem = tooltipItems[0],
chart = tooltipItem.chart as ChartJsChart,
config = chart.config,
xAxisValues = false,
yAxisValues = false,
itemsText = '';
tooltipItems.forEach(tooltipItem => {
let {label, labelValue, labelColor} = this._getItemDetails(tooltipItem, tooltipLabel, tooltipLabelValue, tooltipColor);
if (scout.isOneOf(config.type, Chart.Type.SCATTER)) {
let {x, y} = labelValue as { x: string; y: string };
xAxisValues ||= objects.isString(x);
yAxisValues ||= objects.isString(y);
itemsText += this._createTooltipScatterAttribute(label, x, y, false, labelColor);
} else {
itemsText += this._createTooltipAttribute(label, labelValue as string, false, labelColor);
}
});
// tabular representation for scatter tooltip needs an additional header and footer
if (scout.isOneOf(config.type, Chart.Type.SCATTER)) {
let tableHeader = '<table><tbody>';
let axisLabels = this._getAxisLabels(config);
tableHeader += this._createTooltipScatterAttribute('',
xAxisValues ? axisLabels.x : '', // do not show axis label if no values are shown
yAxisValues ? axisLabels.y : '', // do not show axis label if no values are shown
true);
let tableFooter = '</tbody></table>';
itemsText = strings.box(tableHeader, itemsText, tableFooter);
}
return itemsText;
}
protected _getItemDetails(tooltipItem: TooltipItem<any>, tooltipLabel: TooltipLabelGenerator, tooltipLabelValue: TooltipLabelValueGenerator, tooltipColor: TooltipLabelColorGenerator)
: { label: string; labelValue: string | { x: string; y: string }; labelColor: string } {
let label, labelValue, labelColor;
if (objects.isFunction(tooltipLabel)) {
label = tooltipLabel(tooltipItem);
label = objects.isString(label) ? label : '';
}
if (objects.isFunction(tooltipLabelValue)) {
labelValue = tooltipLabelValue(tooltipItem);
labelValue = objects.isString(labelValue) || objects.isObject(labelValue) ? labelValue : '';
}
if (objects.isFunction(tooltipColor)) {
labelColor = tooltipColor(tooltipItem);
labelColor = objects.isObject(labelColor) ? (labelColor.backgroundColor || '') : '';
}
return {label, labelValue, labelColor};
}
protected _generateTooltipLabel(tooltipItem: TooltipItem<any>): string {
return strings.encode(tooltipItem.dataset.label);
}
protected _generateTooltipLabelValue(tooltipItem: TooltipItem<any>): string | { x: string; y: string } {
let config = tooltipItem.chart.config as ChartConfiguration,
dataset = tooltipItem.dataset;
if (config.type === Chart.Type.BUBBLE) {
return strings.encode(this._formatLabel(dataset.data[tooltipItem.dataIndex].z));
} else if (config.type === Chart.Type.SCATTER) {
return {
x: strings.encode(this._formatXLabel(dataset.data[tooltipItem.dataIndex].x)),
y: strings.encode(this._formatYLabel(dataset.data[tooltipItem.dataIndex].y))
};
}
return strings.encode(this._formatLabel(dataset.data[tooltipItem.dataIndex]));
}
protected _generateTooltipLabelColor(tooltipItem: TooltipItem<any>): TooltipLabelStyle {
let config = tooltipItem.chart.config as ChartConfiguration,
dataset = tooltipItem.dataset,
legendColor, backgroundColor, borderColor, index;
if (scout.isOneOf((dataset.type || config.type), Chart.Type.LINE, Chart.Type.BAR, Chart.Type.BAR_HORIZONTAL, Chart.Type.RADAR, Chart.Type.BUBBLE, Chart.Type.SCATTER)) {
borderColor = dataset.borderColor;
legendColor = Array.isArray(dataset.legendColor) ? dataset.legendColor[tooltipItem.dataIndex] : dataset.legendColor;
index = tooltipItem.datasetIndex;
}
if (scout.isOneOf(config.type, Chart.Type.PIE, Chart.Type.DOUGHNUT, Chart.Type.POLAR_AREA)) {
legendColor = Array.isArray(dataset.legendColor) ? dataset.legendColor[tooltipItem.dataIndex] : dataset.legendColor;
backgroundColor = Array.isArray(dataset.backgroundColor) ? dataset.backgroundColor[tooltipItem.dataIndex] : dataset.backgroundColor;
backgroundColor = this._adjustColorOpacity(backgroundColor, 1);
index = tooltipItem.dataIndex;
}
if (objects.isFunction(legendColor)) {
legendColor = legendColor.call(tooltipItem.chart, index);
}
let tooltipLabelColor = legendColor || backgroundColor || borderColor;
if (!tooltipLabelColor || objects.isFunction(tooltipLabelColor)) {
let defaultTypeTooltipLabelColor;
if (ChartJs.overrides[config.type] && ChartJs.overrides[config.type].plugins && ChartJs.overrides[config.type].plugins.tooltip && ChartJs.overrides[config.type].plugins.tooltip.callbacks) {
defaultTypeTooltipLabelColor = ChartJs.overrides[config.type].plugins.tooltip.callbacks.labelColor;
}
let defaultTooltipLabelColor = defaultTypeTooltipLabelColor || ChartJs.defaults.plugins.tooltip.callbacks.labelColor;
tooltipLabelColor = defaultTooltipLabelColor.call(tooltipItem.chart, tooltipItem).backgroundColor;
}
return {
backgroundColor: tooltipLabelColor
} as TooltipLabelStyle;
}
protected _createTooltipAttribute(label: string, value: string, isTitle: boolean, color?: string): string {
let cssClass = isTitle ? 'attribute title' : 'attribute';
return '<div class="' + cssClass + '">' +
(color ? '<div class="color" style="background-color:' + color + '"></div>' : '') +
(label ? '<label>' + label + '</label>' : '') +
(value ? '<div class="value">' + value + '</div>' : '') +
'</div>';
}
protected _createTooltipScatterAttribute(label: string, xValue: string, yValue: string, isTitle: boolean, color?: string): string {
let cssClass = isTitle ? 'attribute title' : 'attribute';
return '<tr class="' + cssClass + '">' +
'<td class="color-cell">' +
(color ? '<div class="color" style="background-color:' + color + '"></div>' : '') +
'</td>' +
'<td class="label">' + label + '</td>' +
(xValue ? '<td class="value">' + xValue + '</td>' : '') +
(yValue ? '<td class="value">' + yValue + '</td>' : '') +
'</tr>';
}
protected _renderTooltip(context: { chart: ChartJs; tooltip: TooltipModel<any> }) {
let isHideTooltip = context.tooltip.opacity === 0 || context.tooltip.dataPoints.length < 1;
if (isHideTooltip) {
if (this._tooltipTimeoutId) {
clearTimeout(this._tooltipTimeoutId);
this._tooltipTimeoutId = undefined;
}
if (this._tooltip) {
this._tooltip.destroy();
this._tooltip = null;
}
return;
}
let isTooltipShowing = !!this._tooltip;
if (isTooltipShowing) {
this._renderTooltipLater(context);
} else {
// clear timeout before creating a new handler.
// Otherwise, changing the context within the tooltip delay time creates a second handler
// and the first one will always be executed, since the tooltipTimoutId reference to it is lost
clearTimeout(this._tooltipTimeoutId);
this._tooltipTimeoutId = setTimeout(() => this._renderTooltipLater(context), tooltips.DEFAULT_TOOLTIP_DELAY);
}
}
protected _renderTooltipLater(context: { chart: ChartJs; tooltip: TooltipModel<any> }) {
if (!this.rendered || this.removing) {
return;
}
let tooltip = context.tooltip,
dataPoints = tooltip.dataPoints;
if (dataPoints.length < 1) {
return;
}
let firstDataPoint = dataPoints[0],
chart = firstDataPoint.chart;
if (!chart.getDatasetMeta(firstDataPoint.datasetIndex).data[firstDataPoint.dataIndex]) {
return;
}
if (this._tooltip) {
this._tooltip.destroy();
this._tooltip = null;
}
let tooltipOptions = tooltip.options || {} as TooltipOptions,
tooltipCallbacks = tooltipOptions.callbacks || {} as TooltipCallbacks<any>,
tooltipTitle = tooltipCallbacks.title as TooltipTitleGenerator,
tooltipItems = tooltipCallbacks.items,
tooltipLabel = tooltipCallbacks.label,
tooltipLabelValue = tooltipCallbacks.labelValue,
tooltipColor = tooltipCallbacks.labelColor,
tooltipText = '';
if (objects.isFunction(tooltipTitle)) {
tooltipText += arrays.ensure(tooltipTitle(dataPoints)).join('');
}
if (objects.isFunction(tooltipItems)) {
tooltipText += arrays.ensure(tooltipItems(dataPoints, tooltipLabel, tooltipLabelValue, tooltipColor)).join('');
}
let positionAndOffset = this._computeTooltipPositionAndOffset(firstDataPoint);
let offset = new Point(tooltip.caretX + positionAndOffset.offsetX, tooltip.caretY + positionAndOffset.offsetY);
this._tooltip = scout.create({
objectType: Tooltip,
parent: this.chart,
$anchor: this.$canvas,
text: tooltipText,
htmlEnabled: true,
cssClass: strings.join(' ', 'chart-tooltip', tooltipOptions.cssClass),
tooltipPosition: positionAndOffset.tooltipPosition,
tooltipDirection: positionAndOffset.tooltipDirection,
originProducer: $anchor => {
const origin = graphics.offsetBounds($anchor);
origin.height = positionAndOffset.height;
return origin;
},
offsetProducer: origin => offset
});
this._tooltip.render();
this._tooltip.$container
.css('pointer-events', 'none');
let reposition = false,
fontFamily = ((tooltipOptions.titleFont || {}) as FontSpec).family;
if (fontFamily) {
this._tooltip.$container
.css('--chart-tooltip-font-family', fontFamily);
reposition = true;
}
let maxLabelPrefSize = 0;
this._tooltip.$container.find('label').each((idx, elem) => {
maxLabelPrefSize = Math.max(maxLabelPrefSize, graphics.prefSize($(elem)).width);
});
if (maxLabelPrefSize > 0) {
this._tooltip.$container
.css('--chart-tooltip-label-width', Math.min(maxLabelPrefSize, 120) + 'px');
reposition = true;
}
if (reposition) {
this._tooltip.position();
}
}
protected _computeTooltipPositionAndOffset(tooltipItem: TooltipItem<any>): { tooltipPosition: 'top' | 'bottom'; tooltipDirection: 'left' | 'right'; offsetX: number; offsetY: number; height: number } {
let tooltipPosition: 'top' | 'bottom' = 'top',
tooltipDirection: 'left' | 'right' = 'right',
offsetX = 0,
offsetY = 0,
height = 0;
let chart = tooltipItem.chart as ChartJsChart,
datasetIndex = tooltipItem.datasetIndex,
dataIndex = tooltipItem.dataIndex,
config = chart.config,
datasets = config.data.datasets,
dataset = datasets[datasetIndex],
value = dataset.data[dataIndex];
if (this._isHorizontalBar(config)) {
if (objects.isObject(value) && objects.isArray(value.x) && value.x.length === 2) {
let avg = (value.x[0] + value.x[1]) / 2;
tooltipDirection = avg < 0 ? 'left' : 'right';
} else {
tooltipDirection = (value as number) < 0 ? 'left' : 'right';
}
} else if ((dataset.type || config.type) === Chart.Type.BAR) {
tooltipPosition = (value as number) < 0 ? 'bottom' : 'top';
} else if (scout.isOneOf(config.type, Chart.Type.PIE, Chart.Type.DOUGHNUT, Chart.Type.POLAR_AREA)) {
let element = (chart.getDatasetMeta(datasetIndex).data[dataIndex] as unknown as ArcElement).getProps(['startAngle', 'endAngle']);
let startAngle = element.startAngle,
endAngle = element.endAngle,
angle = (startAngle + endAngle) / 2;
tooltipPosition = (0 <= angle && angle < Math.PI) ? 'bottom' : 'top';
tooltipDirection = (-Math.PI / 2 <= angle && angle < Math.PI / 2) ? 'right' : 'left';
} else if (config.type === Chart.Type.RADAR) {
let element = (chart.getDatasetMeta(datasetIndex).data[dataIndex] as unknown as PointElement).getProps(['angle']);
let angle = element.angle as number;
tooltipPosition = (0 <= angle && angle < Math.PI) ? 'bottom' : 'top';
tooltipDirection = (-Math.PI / 2 <= angle && angle < Math.PI / 2) ? 'right' : 'left';
} else if (scout.isOneOf(config.type, Chart.Type.BUBBLE, Chart.Type.SCATTER)) {
let element = chart.getDatasetMeta(datasetIndex).data[dataIndex];
let chartArea = chart.chartArea,
mid = chartArea.left + (chartArea.width / 2);
tooltipDirection = element.x < mid ? 'left' : 'right';
}
if (this._isHorizontalBar(config)) {
let element = (chart.getDatasetMeta(datasetIndex).data[dataIndex] as unknown as BarElement).getProps(['height', 'width']);
height = element.height;
let width = element.width,
// golden ratio: (a + b) / a = a / b = PHI
// and a + b = width
// -> b = width / (PHI + 1)
b = width / (PHI + 1);
offsetY = -height / 2;
offsetX = tooltipDirection === 'left' ? b : -b;
} else if (scout.isOneOf(config.type, Chart.Type.LINE, Chart.Type.BUBBLE, Chart.Type.SCATTER, Chart.Type.RADAR) || dataset.type === Chart.Type.LINE) {
let element = chart.getDatasetMeta(datasetIndex).data[dataIndex] as unknown as PointElement;
let options = element.options as unknown as PointHoverOptions,
offset = options.hoverRadius + options.hoverBorderWidth;
if (config.type === Chart.Type.BUBBLE) {
offset += (value as BubbleDataPoint).r;
}
height = 2 * offset;
offsetY = -offset;
} else if (scout.isOneOf(config.type, Chart.Type.PIE, Chart.Type.DOUGHNUT, Chart.Type.POLAR_AREA)) {
let element = (chart.getDatasetMeta(datasetIndex).data[dataIndex] as unknown as ArcElement).getProps(['startAngle', 'endAngle', 'innerRadius', 'outerRadius']);
let startAngle = element.startAngle,
endAngle = element.endAngle,
angle = (startAngle + endAngle) / 2,
innerRadius = element.innerRadius,
outerRadius = element.outerRadius,
offset = (outerRadius - innerRadius) / 2;
offsetX = offset * Math.cos(angle);
offsetY = offset * Math.sin(angle);
}
return {tooltipPosition, tooltipDirection, offsetX, offsetY, height};
}
protected _adjustGrid(config: ChartConfig) {
if (!config) {
return;
}
config.options = $.extend(true, {}, config.options);
this._adjustScalesR(config);
this._adjustScalesXY(config);
}
protected _adjustScalesR(config: ChartConfig) {
if (!config || !config.type || !config.options) {
return;
}
if (scout.isOneOf(config.type, Chart.Type.POLAR_AREA, Chart.Type.RADAR)) {
config.options = $.extend(true, {}, {
scales: {
r: {}
}
}, config.options);
}
let options = config.options,
scales = options ? options.scales : {};
if (scales && scales.r) {
scales.r = $.extend(true, {}, {
minSpaceBetweenTicks: 35,
beginAtZero: true,
angleLines: {
display: false
},
ticks: {
callback: this._labelFormatter
},
pointLabels: {
callback: this._labelFormatter,
font: {
size: ChartJs.defaults.font.size
}
}
}, scales.r);
}
}
protected _adjustSc