@eclipse-scout/chart
Version:
Eclipse Scout chart
548 lines (489 loc) • 16.7 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, ChartEventMap, ChartJsRenderer, ChartLayout, ChartModel, FulfillmentChartRenderer, SalesfunnelChartRenderer, SpeedoChartRenderer, VennChartRenderer} from '../index';
import {aria, arrays, ColorScheme, colorSchemes, DeepPartial, EnumObject, HtmlComponent, InitModelOf, objects, Widget} from '@eclipse-scout/core';
import {GreenAreaPosition} from './SpeedoChartRenderer';
import {CategoryScaleOptions, ChartConfiguration, ChartOptions, LinearScaleOptions, LogarithmicScaleOptions, RadialLinearScaleOptions, ScaleType, TimeScaleOptions as ChartJsTimeScaleOptions} from 'chart.js';
import $ from 'jquery';
export class Chart extends Widget implements ChartModel {
declare model: ChartModel;
declare eventMap: ChartEventMap;
declare self: Chart;
data: ChartData;
config: ChartConfig;
checkedItems: ClickObject[];
chartRenderer: AbstractChartRenderer;
/** @internal */
_updatedOnce: boolean;
protected _updateChartTimeoutId: number;
protected _updateChartOpts: UpdateChartOptions;
protected _updateChartOptsWhileNotAttached: UpdateChartOptions[];
constructor() {
super();
this.$container = null;
this.data = null;
this.config = null;
this.checkedItems = [];
this.chartRenderer = null;
this._updateChartTimeoutId = null;
this._updateChartOpts = null;
this._updateChartOptsWhileNotAttached = [];
this._updatedOnce = false;
}
static Type = {
PIE: 'pie',
LINE: 'line',
BAR: 'bar',
BAR_HORIZONTAL: 'horizontalBar',
COMBO_BAR_LINE: 'comboBarLine',
FULFILLMENT: 'fulfillment',
SPEEDO: 'speedo',
SALESFUNNEL: 'salesfunnel',
VENN: 'venn',
DOUGHNUT: 'doughnut',
POLAR_AREA: 'polarArea',
RADAR: 'radar',
BUBBLE: 'bubble',
SCATTER: 'scatter'
} as const;
static Position = {
TOP: 'top',
BOTTOM: 'bottom',
LEFT: 'left',
RIGHT: 'right',
CENTER: 'center'
} as const;
static DEFAULT_ANIMATION_DURATION = 600; // ms
static DEFAULT_DEBOUNCE_TIMEOUT = 100; // ms
protected override _init(model: InitModelOf<this>) {
super._init(model);
this.setConfig(this.config);
this._setData(this.data);
}
protected override _render() {
this.$container = this.$parent.appendDiv('chart');
aria.role(this.$container, 'none'); // ignore this container for screen readers, they care about the chart inside
this.htmlComp = HtmlComponent.install(this.$container, this.session);
this.htmlComp.setLayout(new ChartLayout(this));
// !!! Do _not_ update the chart here, because usually the container size
// !!! is not correct anyway during the render phase. The ChartLayout
// !!! will eventually call updateChart() when the layout is validated.
}
protected override _renderProperties() {
super._renderProperties();
this._renderClickable();
this._renderCheckable();
this._renderChartType();
this._renderColorScheme();
this.updateChart({
requestAnimation: true,
debounce: Chart.DEFAULT_DEBOUNCE_TIMEOUT
});
}
protected override _renderOnAttach() {
super._renderOnAttach();
const updateChartOptsWhileNotAttached = this._updateChartOptsWhileNotAttached.splice(0);
if (!this.chartRenderer?.isDetachSupported()) {
// the chartRenderer does not support detach => recreate it
this._updateChartRenderer();
updateChartOptsWhileNotAttached.forEach(opts => delete opts.requestAnimation);
updateChartOptsWhileNotAttached.push({requestAnimation: false});
}
updateChartOptsWhileNotAttached.forEach(opts => this.updateChart($.extend(true, {}, opts, {debounce: true})));
}
protected override _remove() {
if (this.chartRenderer) {
this.chartRenderer.remove(false);
}
this.$container.remove();
this.$container = null;
}
setData(data: ChartData) {
this.setProperty('data', data);
this.setCheckedItems(this.checkedItems);
}
protected _setData(data: ChartData) {
if (data) {
data = $.extend({axes: []}, data);
}
this._setProperty('data', data);
}
protected _renderData() {
this.updateChart({
requestAnimation: true,
debounce: Chart.DEFAULT_DEBOUNCE_TIMEOUT,
onlyUpdateData: true
});
}
setConfig(config: ChartConfig) {
let defaultConfig = {
type: Chart.Type.PIE,
options: {
autoColor: true,
colorMode: ChartColorMode.AUTO,
colorScheme: colorSchemes.ColorSchemeId.DEFAULT,
transparent: false,
maxSegments: 5,
adjustGridMaxMin: true,
clickable: false,
checkable: false,
animation: {
duration: Chart.DEFAULT_ANIMATION_DURATION
},
plugins: {
datalabels: {
display: false
},
tooltip: {
enabled: true
},
legend: {
display: true,
clickable: false,
position: Chart.Position.RIGHT,
pointsVisible: true
}
}
}
};
config = $.extend(true, {}, defaultConfig, config);
config.options.colorScheme = colorSchemes.ensureColorScheme(config.options.colorScheme);
if (objects.equalsRecursive(this.config, config)) {
return;
}
// check if only data has changed
let oldConfigWithNewData = $.extend(true, {}, this.config);
if (config.data) {
oldConfigWithNewData.data = config.data;
} else {
delete oldConfigWithNewData.data;
}
// the label map is technically part of the config, but it is handled as data. Therefore, it is excluded from this check.
let transferLabelMap = (source, target, identifier) => {
if (!source || !target || !identifier) {
return;
}
// Property not set on source -> remove from target
if (!source.options || !source.options[identifier]) {
if (target.options) {
delete target.options[identifier];
}
if (target.options && objects.isEmpty(target.options.scales) && !(source.options && source.options.scales)) {
delete target.options.scales;
}
if (objects.isEmpty(target.options) && !source.options) {
delete target.options;
}
return;
}
target.options[identifier] = source.options[identifier];
};
transferLabelMap(config, oldConfigWithNewData, 'xLabelMap');
transferLabelMap(config, oldConfigWithNewData, 'yLabelMap');
if (objects.equalsRecursive(oldConfigWithNewData, config)) {
this._setProperty('config', config);
if (this.rendered) {
this._renderConfig(true);
}
this.setCheckedItems(this.checkedItems);
return;
}
if (this.rendered && this.config && this.config.type) {
this.$container.removeClass(this.config.type + '-chart');
}
this.setProperty('config', config);
this.setCheckedItems(this.checkedItems);
this._updateChartRenderer();
}
protected _renderConfig(onlyUpdateData: boolean) {
this._renderClickable();
this._renderCheckable();
this._renderChartType();
this._renderColorScheme();
this.updateChart({
requestAnimation: true,
debounce: Chart.DEFAULT_DEBOUNCE_TIMEOUT,
onlyUpdateData: onlyUpdateData
});
}
setCheckedItems(checkedItems: ClickObject[]) {
this.setProperty('checkedItems', arrays.ensure(this._filterCheckedItems(checkedItems)));
}
protected _filterCheckedItems(checkedItems: ClickObject[]): ClickObject[] {
if (!Array.isArray(checkedItems)) {
return checkedItems;
}
let datasetLengths = [];
if (this.data && this.data.chartValueGroups) {
this.data.chartValueGroups.forEach(chartValueGroup => datasetLengths.push(chartValueGroup.values.length));
} else if (this.config && this.config.data) {
this.config.data.datasets.forEach(dataset => datasetLengths.push(dataset.data.length));
}
let filteredCheckedItems = checkedItems.filter(item => datasetLengths[item.datasetIndex] && item.dataIndex < datasetLengths[item.datasetIndex]);
if (filteredCheckedItems.length < checkedItems.length) {
return filteredCheckedItems;
}
return checkedItems;
}
protected _renderCheckedItems() {
if (this.chartRenderer) {
this.chartRenderer.renderCheckedItems();
}
}
protected override _renderEnabled() {
this.updateChart();
}
protected _renderClickable() {
this.$container.toggleClass('clickable', this.config.options.clickable);
}
protected _renderCheckable() {
this.$container.toggleClass('checkable', this.config.options.checkable);
}
protected _renderChartType() {
this.$container.addClass(this.config.type + '-chart');
}
protected _renderColorScheme() {
colorSchemes.toggleColorSchemeClasses(this.$container, this.config.options.colorScheme);
}
updateChart(opts?: UpdateChartOptions) {
opts = opts || {};
opts.onlyUpdateData = opts.onlyUpdateData && this.chartRenderer && this.chartRenderer.isDataUpdatable();
opts.enforceRerender = !opts.onlyUpdateData && !opts.onlyRefresh;
// Cancel previously scheduled update and merge opts
if (this._updateChartTimeoutId) {
clearTimeout(this._updateChartTimeoutId);
if (this._updateChartOpts) {
// Inherit 'true' values from previously scheduled updates
opts.requestAnimation = opts.requestAnimation || this._updateChartOpts.requestAnimation;
opts.onlyUpdateData = opts.onlyUpdateData || this._updateChartOpts.onlyUpdateData;
opts.onlyRefresh = opts.onlyRefresh || this._updateChartOpts.onlyRefresh;
opts.enforceRerender = opts.enforceRerender || this._updateChartOpts.enforceRerender;
}
this._updateChartTimeoutId = null;
this._updateChartOpts = null;
}
let updateChartImplFn = updateChartImpl.bind(this);
let doDebounce = (opts.debounce === true || typeof opts.debounce === 'number');
if (doDebounce) {
this._updateChartOpts = opts;
if (typeof opts.debounce === 'number') {
this._updateChartTimeoutId = setTimeout(updateChartImplFn, opts.debounce);
} else {
this._updateChartTimeoutId = setTimeout(updateChartImplFn);
}
} else {
updateChartImplFn();
}
// ---- Helper functions -----
function updateChartImpl() {
this._updateChartTimeoutId = null;
this._updateChartOpts = null;
if (!this.$container || !this.$container.isAttached()) {
this._updateChartOptsWhileNotAttached.push(opts);
return;
}
this._updatedOnce = true;
if (!this.chartRenderer) {
return; // nothing to render when there is no renderer.
}
if (opts.enforceRerender) {
this.chartRenderer.remove(this.chartRenderer.shouldAnimateRemoveOnUpdate(opts), chartAnimationStopping => {
if (this.removing || chartAnimationStopping) {
// prevent exceptions trying to render after navigated away, and do not update/render while a running animation is being stopped
return;
}
this.chartRenderer.render(opts.requestAnimation);
this.trigger('chartRender');
});
} else if (opts.onlyUpdateData) {
this.chartRenderer.updateData(opts.requestAnimation);
} else if (opts.onlyRefresh) {
this.chartRenderer.refresh();
}
}
}
protected _resolveChartRenderer(): AbstractChartRenderer {
switch (this.config.type) {
case Chart.Type.FULFILLMENT:
return new FulfillmentChartRenderer(this);
case Chart.Type.SPEEDO:
return new SpeedoChartRenderer(this);
case Chart.Type.SALESFUNNEL:
return new SalesfunnelChartRenderer(this);
case Chart.Type.VENN:
return new VennChartRenderer(this);
case Chart.Type.BAR:
case Chart.Type.BAR_HORIZONTAL:
case Chart.Type.LINE:
case Chart.Type.COMBO_BAR_LINE:
case Chart.Type.PIE:
case Chart.Type.DOUGHNUT:
case Chart.Type.POLAR_AREA:
case Chart.Type.RADAR:
case Chart.Type.BUBBLE:
case Chart.Type.SCATTER:
return new ChartJsRenderer(this);
}
return null;
}
protected _updateChartRenderer() {
this.chartRenderer && this.chartRenderer.remove();
this.setProperty('chartRenderer', this._resolveChartRenderer());
}
handleValueClick(clickedItem: ClickObject, originalEvent?: Event) {
if (this.config.options.checkable) {
let checkedItems = [...this.checkedItems],
checkedItem = checkedItems.filter(item => item.datasetIndex === clickedItem.datasetIndex && item.dataIndex === clickedItem.dataIndex)[0];
if (checkedItem) {
arrays.remove(checkedItems, checkedItem);
} else {
checkedItems.push(clickedItem);
}
this.setCheckedItems(checkedItems);
}
this.trigger('valueClick', {
data: clickedItem,
originalEvent
});
}
handleNonValueClick(originalEvent?: Event) {
this.trigger('nonValueClick', {
originalEvent
});
}
handleLegendClick(legentItemIndex: number, originalEvent?: Event) {
this.trigger('legendItemClick', {
legendItemIndex: legentItemIndex,
originalEvent: originalEvent
});
}
}
export type ChartData = {
axes: ChartAxis[][];
chartValueGroups: ChartValueGroup[];
};
export type ChartAxis = {
label: string;
};
export type ChartValueGroup = {
type?: string;
groupName?: string;
values: number[] | Record<string, number>[];
colorHexValue?: string | string[];
cssClass?: string;
};
export type ChartConfig = Partial<Omit<ChartConfiguration, 'type' | 'options'>> & {
type: ChartType;
options?: ChartConfigOptions;
};
export type ChartConfigOptions = Omit<ChartOptions, 'scales'> & {
autoColor?: boolean;
colorMode?: ChartColorMode;
colorScheme?: ColorScheme | string;
transparent?: boolean;
maxSegments?: number;
otherSegmentClickable?: boolean;
adjustGridMaxMin?: boolean;
clickable?: boolean;
checkable?: boolean;
scaleLabelByTypeMap?: Record<ChartType, Record<string, string>>;
numberFormatter?: NumberFormatter;
reformatLabels?: boolean;
handleResize?: boolean;
animation?: {
duration?: number;
};
scales?: {
x?: CartesianChartScale;
y?: CartesianChartScale;
yDiffType?: CartesianChartScale;
r?: RadialChartScale;
};
bubble?: {
sizeOfLargestBubble?: number;
minBubbleSize?: number;
};
fulfillment?: {
startValue?: number;
};
salesfunnel?: {
normalized?: boolean;
calcConversionRate?: boolean;
};
speedo?: {
greenAreaPosition?: GreenAreaPosition;
};
venn?: {
numberOfCircles?: 1 | 2 | 3;
};
plugins?: {
legend?: {
clickable?: boolean;
pointsVisible?: boolean;
};
};
};
export type RadialChartScale = DeepPartial<RadialLinearScaleOptions> & {
type?: ScaleType;
minSpaceBetweenTicks?: number;
};
export type CartesianChartScale = DeepPartial<LinearScaleOptions | CategoryScaleOptions | TimeScaleOptions | LogarithmicScaleOptions> & {
type?: ScaleType;
minSpaceBetweenTicks?: number;
};
export type TimeScaleOptions = Omit<ChartJsTimeScaleOptions, 'min' | 'max'> & {
min?: string | number | Date | (() => string | number | Date);
max?: string | number | Date | (() => string | number | Date);
};
export type ChartType = EnumObject<typeof Chart.Type>;
export type ChartPosition = EnumObject<typeof Chart.Position>;
export type NumberFormatter = (label: number | string, defaultFormatter: (label: number | string) => string) => string;
/**
* Determines what parts of the chart data is colored with the same colors.
*/
export enum ChartColorMode {
/**
* Uses one of the other options depending on the chart type.
*/
AUTO = 'auto',
/**
* Uses a different color for each dataset.
*/
DATASET = 'dataset',
/**
* Uses a different color for each datapoint in a dataset but the n-th datapoint in each dataset will be colored using the same color.
*/
DATA = 'data'
}
export type ClickObject = {
datasetIndex: number;
dataIndex: number;
xIndex?: number;
yIndex?: number;
};
export type UpdateChartOptions = {
/**
* Default is false.
*/
requestAnimation?: boolean;
/**
* Default is 0.
*/
debounce?: number | boolean;
/**
* Default is false.
*/
onlyUpdateData?: boolean;
/**
* Default is false.
*/
onlyRefresh?: boolean;
enforceRerender?: boolean;
};