diff --git a/documents/src/pages/elements/chart.md b/documents/src/pages/elements/chart.md index eec07b0eb1..723513d567 100644 --- a/documents/src/pages/elements/chart.md +++ b/documents/src/pages/elements/chart.md @@ -1225,3 +1225,48 @@ ef-chart { ``` :: +## Bundle optimisation + +Although `import "@refinitiv-ui/chart";` is easy to use as it provides all components of chart.js, you application might not be using all of them. To import only what is needed, start with `import "@refinitiv-ui/chart/bare";`. Then, import more components from chart.js manually according to your need. Check [Chart.js Bundle Optimisation](https://www.chartjs.org/docs/4.2.1/getting-started/integration.html#bundle-optimization) for what needed to be import for each chart type. This could reduce your bundle size related to chart.js by around 20%. + +Here is a example for simple line chart: + +```javascript +import '@refinitiv-ui/elements/chart/bare'; + +import { + Chart as ChartJS, + LineController, + LineElement, + PointElement, + CategoryScale, + LinearScale +} from 'chart.js'; + +ChartJS.register( + LineController, + LineElement, + PointElement, + CategoryScale, + LinearScale +); +``` + +EF provides center label plugin for doughnut chart. To use it: + +```javascript +import '@refinitiv-ui/elements/chart/bare'; +import { doughnutCenterLabelPlugin } from '@refinitiv-ui/elements/chart/plugins'; + +import { + Chart as ChartJS, + DoughnutController, + ArcElement +} from 'chart.js'; + +ChartJS.register( + DoughnutController, + ArcElement, + doughnutCenterLabelPlugin +); +``` diff --git a/packages/elements/package.json b/packages/elements/package.json index 7fc2cf32bc..8fabae5921 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -71,6 +71,8 @@ "./card/themes/solar/charcoal": "./lib/card/themes/solar/charcoal/index.js", "./card/themes/solar/pearl": "./lib/card/themes/solar/pearl/index.js", "./chart": "./lib/chart/index.js", + "./chart/bare": "./lib/chart/bare.js", + "./chart/plugins": "./lib/chart/plugins/index.js", "./chart/themes/halo/dark": "./lib/chart/themes/halo/dark/index.js", "./chart/themes/halo/light": "./lib/chart/themes/halo/light/index.js", "./chart/themes/solar/charcoal": "./lib/chart/themes/solar/charcoal/index.js", @@ -366,4 +368,4 @@ "publishConfig": { "access": "public" } -} +} \ No newline at end of file diff --git a/packages/elements/src/chart/__test__/__snapshots__/chart.test.snap.js b/packages/elements/src/chart/__test__/__snapshots__/chart.test.snap.js index ff4eb05615..6380d074d9 100644 --- a/packages/elements/src/chart/__test__/__snapshots__/chart.test.snap.js +++ b/packages/elements/src/chart/__test__/__snapshots__/chart.test.snap.js @@ -2,9 +2,9 @@ export const snapshots = {}; snapshots["chart/Chart Check chart types DOM structure is correct"] = -`
+`
- +
@@ -12,12 +12,15 @@ snapshots["chart/Chart Check chart types DOM structure is correct"] = /* end snapshot chart/Chart Check chart types DOM structure is correct */ snapshots["chart/Chart Check chart types DOM structure of chart with config is correct"] = -`
- +`
+ Line Chart - Price of TRI.N in 2016
- +
diff --git a/packages/elements/src/chart/bare.ts b/packages/elements/src/chart/bare.ts new file mode 100644 index 0000000000..95544e68f3 --- /dev/null +++ b/packages/elements/src/chart/bare.ts @@ -0,0 +1 @@ +export * from './elements/chart.js'; diff --git a/packages/elements/src/chart/elements/chart.ts b/packages/elements/src/chart/elements/chart.ts new file mode 100644 index 0000000000..7c4f40a34a --- /dev/null +++ b/packages/elements/src/chart/elements/chart.ts @@ -0,0 +1,600 @@ +import { + BasicElement, + html, + css, + nothing, + PropertyValues, + TemplateResult, + CSSResultGroup +} from '@refinitiv-ui/core'; +import { customElement } from '@refinitiv-ui/core/decorators/custom-element.js'; +import { property } from '@refinitiv-ui/core/decorators/property.js'; +import { ref, createRef, Ref } from '@refinitiv-ui/core/directives/ref.js'; +import { color as parseColor } from '@refinitiv-ui/utils/color.js'; +import { VERSION } from '../../version.js'; + +import { Chart as ChartJS } from 'chart.js'; +import type { + ChartConfiguration, + ChartDataset, + ChartOptions, + ChartType, + Color, + LegendItem, + LineControllerDatasetOptions, + Plugin, + UpdateMode +} from 'chart.js'; + +import 'chartjs-adapter-date-fns'; + +import { + merge, + MergeObject, + DatasetColors +} from '../helpers/index.js'; + +import '../../header/index.js'; + +const CSS_COLOR_PREFIX = '--chart-color-'; + +/* Make ChartJS to know our plugin + * https://www.chartjs.org/docs/latest/developers/plugins.html#typescript-typings + */ +declare module 'chart.js' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface PluginOptionsByType { + 'ef-chart': object; + } +} + +/** + * Charting component that uses ChartJS library + */ +@customElement('ef-chart') +export class Chart extends BasicElement { + + /** + * Element version number + * @returns version number + */ + static get version (): string { + return VERSION; + } + + /** + * A `CSSResultGroup` that will be used + * to style the host, slotted children + * and the internal template of the element. + * @return CSS template + */ + static get styles (): CSSResultGroup { + return css` + :host { + display: block; + overflow: hidden; + position: relative; + } + :host::before { + content: ''; + display: block; + padding-top: 60%; + min-height: 300px; + box-sizing: border-box; + } + [part=container] { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: flex; + flex-direction: column; + } + [part=chart] { + flex: 1 1 auto; + position: relative; + } + [part=title] { + margin-bottom: 12px; + } + canvas { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + `; + } + + /** + * Chart.js object + * @type {ChartJS | null} + */ + public chart: ChartJS | null = null; + + /** + * Chart configurations. Same configuration as ChartJS + * @type {ChartConfiguration | null} + */ + @property({ type: Object }) + public config: ChartConfiguration | null = null; + + /** + * Canvas element used to render Chart + */ + protected canvas: Ref = createRef(); + + /** + * Required properties, needed for chart to work correctly. + * @returns config + */ + protected get requiredConfig (): ChartOptions { + return { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: false + } + } + }; + } + + /** + * List of available chart colors + * @type {string[]} + * @returns {string[]}List of available chart colors + */ + public get colors (): string[] { + const colors: string[] = []; + + for (let index = 1; ; index++) { + const color = this.getComputedVariable(`${CSS_COLOR_PREFIX}${index}`); + if (!color) { + break; + } + colors.push(color); + } + + return colors; + } + + /** + * Returns the chart title + * @returns chart title + */ + protected get chartTitle (): string { + const title = this.config?.options?.plugins?.title?.text; + + if (title) { + return typeof title === 'string' ? title : title.join(); + } + + return ''; + } + + /** + * Returns a dataset array + * @returns dataset array + */ + protected get datasets (): ChartDataset[] { + return this.config?.data?.datasets || []; + } + + /** + * Invoked whenever the element is updated + * @param changedProperties Map of changed properties with old values + * @returns {void} + */ + protected updated (changedProperties: PropertyValues): void { + super.updated(changedProperties); + if (changedProperties.has('config')) { + this.onConfigChange(); + } + } + + /** + * Element connected + * @returns {void} + */ + public connectedCallback (): void { + super.connectedCallback(); + this.setGlobalConfig(); + if (this.canvas.value) { + this.createChart(); + } + } + + /** + * Element disconnected + * @returns {void} + */ + public disconnectedCallback (): void { + super.disconnectedCallback(); + this.destroyChart(); + } + + /** + * Create plugin to set our theme into ChartJS lifecycle + * @returns Created plugin + */ + private createPlugin (): Plugin { + return { + id: 'ef-chart', + beforeInit: (chart: ChartJS) => { + const option: ChartOptions = this.themableChartOption; + merge(chart.config.options as ChartOptions, option); + }, + beforeUpdate: this.decorateColors + }; + } + + /** + * Themable parts of the config. + * This will be merged into the configuration object. + * @returns Chart option with theme + */ + protected get themableChartOption (): ChartOptions { + const boxWidth = this.cssVarAsNumber('--legend-key-box-width', '10') as number; + let boxHeight = Number(getComputedStyle(this).getPropertyValue('font-size').replace('px', '')); + if (this.config?.options?.plugins?.legend?.labels?.usePointStyle) { + boxHeight = boxWidth; + } + + return { + animation: { + duration: this.cssVarAsNumber('--animation-duration', '0') + }, + elements: { + line: { + borderWidth: this.cssVarAsNumber('--line-width', '1'), + tension: this.cssVarAsNumber('--line-tension', '0.5') + } + }, + plugins: { + tooltip: { + backgroundColor: this.getComputedVariable('--tooltip-background-color', 'transparent'), + titleColor: this.getComputedVariable('--tooltip-title-color', 'transparent'), + bodyColor: this.getComputedVariable('--tooltip-body-color', 'transparent'), + cornerRadius: this.cssVarAsNumber('--tooltip-border-radius', '0'), + caretSize: this.cssVarAsNumber('--tooltip-caret-size', '0'), + padding: { + x: this.cssVarAsNumber('--tooltip-padding-x', '--tooltip-padding', '0'), + y: this.cssVarAsNumber('--tooltip-padding-y', '--tooltip-padding', '0') + }, + titleSpacing: this.cssVarAsNumber('--tooltip-title-spacing', '0'), + displayColors: false + }, + legend: { + position: ['pie', 'doughnut'].includes(this.config?.type || '') ? 'right' : 'top', + labels: { + boxWidth, + boxHeight, + generateLabels: this.generateLegendLabels + } + } + } + }; + } + + /** + * Set global configuration of ChartJS + * @returns {void} + */ + private setGlobalConfig (): void { + const cssStyle = getComputedStyle(this); + + // Set font globals + ChartJS.defaults.color = cssStyle.getPropertyValue('color'); + ChartJS.defaults.font.family = cssStyle.getPropertyValue('font-family'); + ChartJS.defaults.font.size = Number(cssStyle.getPropertyValue('font-size').replace('px', '')); + ChartJS.defaults.font.style = cssStyle.getPropertyValue('font-style') as 'normal' | 'italic' | 'oblique' | 'initial' | 'inherit' | undefined; + // Set global grid color + ChartJS.defaults.scale.grid.color = (line) => { + return line.index === 0 ? this.getComputedVariable('--zero-line-color', 'transparent') : this.getComputedVariable('--grid-line-color', 'transparent'); + }; + if (this.config?.type === 'polarArea' || this.config?.type === 'radar') { + ChartJS.defaults.scales.radialLinear.ticks.showLabelBackdrop = false; + } + } + + /** + * Handles a change of configuration object. + * This does not fire when a property of the config object changes, + * for this use this.updateChart() to apply changes. + * @returns {void} + */ + protected onConfigChange (): void { + if (this.config) { + this.createChart(); + } + } + + /** + * Get as CSS variable and tries to convert it into a usable number + * @returns The value as a number, or, undefined if NaN. + */ + protected cssVarAsNumber (...args: string[]): number | undefined { + const result = Number(this.getComputedVariable(...args).replace(/\D/g, '')); + return isNaN(result) ? undefined : result; + } + + /** + * Inject theme color into each datasets + * @param chart Chart.js instance + * @returns {void} + */ + protected decorateColors = (chart: ChartJS): void => { + chart.config.data.datasets.forEach((dataset, datasetIndex) => { + let colors; + let borderColor; + let backgroundColor; + const isMultipleDatasets = (chart.config.data.datasets.length > 1); + // From old requirement, Only line, radar, scatter, polarArea type are opaque backgroundColor + switch (dataset.type ?? this.config?.type) { + case 'line': + case 'radar': + case 'scatter': + colors = this.generateColors(false, 1, datasetIndex); + if (!dataset.borderColor) { + dataset.borderColor = colors.solid; + } + if (!dataset.backgroundColor) { + dataset.backgroundColor = colors.opaque; + } + if (!(dataset as LineControllerDatasetOptions).pointBackgroundColor) { + (dataset as LineControllerDatasetOptions).pointBackgroundColor = colors.solid; + } + if (!(dataset as LineControllerDatasetOptions).pointBorderColor) { + (dataset as LineControllerDatasetOptions).pointBorderColor = colors.solid; + } + break; + + // These types, Colors are set to an array. + case 'doughnut': + case 'pie': + case 'polarArea': + const index = isMultipleDatasets ? 0 : datasetIndex; + colors = this.generateColors(true, dataset.data ? dataset.data.length : 1, index); + borderColor = isMultipleDatasets ? this.getComputedVariable('--multi-dataset-border-color', '#fff') : colors.solid; + backgroundColor = this.config?.type === 'polarArea' ? colors.opaque : colors.solid; + if (!dataset.borderColor) { + dataset.borderColor = borderColor; + } + if (!dataset.backgroundColor) { + dataset.backgroundColor = backgroundColor; + } + // Add more colors if items aren't enough + if (Array.isArray(dataset.borderColor) && Array.isArray(borderColor) && dataset.borderColor.length < borderColor.length) { + merge(dataset.borderColor, borderColor); + } + if (Array.isArray(dataset.backgroundColor) && Array.isArray(backgroundColor) && dataset.backgroundColor.length < backgroundColor.length) { + merge(dataset.backgroundColor, backgroundColor); + } + break; + + // These types, Colors could be string or array + case 'bar': + case 'bubble': + colors = this.generateColors(!isMultipleDatasets, !isMultipleDatasets && dataset.data ? dataset.data.length : 1, datasetIndex); + borderColor = colors.solid; + backgroundColor = this.config?.type === 'bubble' ? colors.opaque : colors.solid; + if (!dataset.borderColor) { + dataset.borderColor = borderColor; + } + if (!dataset.backgroundColor) { + dataset.backgroundColor = backgroundColor; + } + // Add more colors if items aren't enough + if (Array.isArray(dataset.borderColor) && Array.isArray(borderColor) && dataset.borderColor.length < borderColor.length) { + merge(dataset.borderColor, borderColor); + } + + if (Array.isArray(dataset.backgroundColor) && Array.isArray(backgroundColor) && dataset.backgroundColor.length < backgroundColor.length) { + merge(dataset.backgroundColor, backgroundColor); + } + break; + // For other types + default: + colors = this.generateColors(false, dataset.data.length, datasetIndex); + if (!dataset.borderColor) { + dataset.borderColor = colors.solid; + } + if (!dataset.backgroundColor) { + dataset.backgroundColor = colors.solid; + } + break; + } + }); + }; + + /** + * Generates the legend labels on a given chart + * @param chart Chart.js instance + * @returns Array of label configurations + */ + protected generateLegendLabels = (chart: ChartJS): LegendItem[] => { + const chartType = (chart.config as ChartConfiguration).type; + if (!chartType) { + return []; + } + + let legends: LegendItem[] = []; + const datasets = chart.config.data.datasets; + + if ( + datasets.length + && chart?.config?.options?.plugins?.legend + && Array.isArray(datasets[0].backgroundColor) + ) { + + if (ChartJS.overrides.pie.plugins.legend.labels.generateLabels) { + legends = ChartJS.overrides.pie.plugins.legend.labels.generateLabels(chart); + } + + // Customize for doughnut chart change border color to background color + if (['pie', 'doughnut'].includes(chartType) && this.datasets.length > 1) { + legends.forEach((label: LegendItem) => { + label.strokeStyle = label.fillStyle; + }); + } + + return legends; + } + + if (ChartJS.defaults.plugins.legend.labels.generateLabels) { + legends = ChartJS.defaults.plugins.legend.labels.generateLabels(chart); + } + legends.forEach((legend, i) => { + legend.lineWidth = Number(datasets[i].borderWidth) || 0; + switch (datasets[i].type ?? chartType) { + case 'line': + case 'radar': + case 'bubble': + case 'polarArea': + legend.fillStyle = (datasets[i] as LineControllerDatasetOptions).borderColor as Color; + break; + // For other chart types + default: + break; + } + }); + return legends; + }; + + /** + * Merges all the different layers of the config. + * @returns {void} + */ + protected mergeConfigs (): void { + if (!this.config) { + return; + } + + let plugins: Plugin[] = [ + this.createPlugin() + ]; + + if (Array.isArray(this.config.plugins) && this.config.plugins.length > 0) { + plugins = [ + ...plugins, + ...this.config.plugins + ]; + } + + merge(this.config as unknown as MergeObject, + { + plugins, + options: this.requiredConfig + } as MergeObject, + true + ); + } + + /** + * Generates internal solid and opaque color set for a dataset + * @param isArray Flag to return result in array or not e.g. doughnut, pie, etc + * @param amount Amount of colors required + * @param shift Positional shift of the color start point + * @returns Solid and opaque color values + */ + protected generateColors (isArray: boolean, amount: number, shift: number): DatasetColors { + const solid = []; + const opaque = []; + const alpha = Number(this.getComputedVariable('--fill-opacity', '0.2')); + + amount = isArray ? amount : 1; + + for (let i = shift; i < amount + shift; i++) { + const color = this.colors[i % this.colors.length]; + solid.push(color); + + const opaqueColor = parseColor(color); + if (opaqueColor) { + opaqueColor.opacity = alpha; + opaque.push(opaqueColor.toString()); + } + } + return { + solid: isArray ? solid : solid[0], + opaque: isArray ? opaque : opaque[0] + }; + } + + /** + * Creates a chart after config has changed, + * or, the element has been connected to the DOM + * @returns {void} + */ + protected createChart (): void { + const canvas = this.canvas.value; + if (canvas && this.config) { + this.destroyChart(); + this.mergeConfigs(); + + this.chart = new ChartJS(canvas, this.config); + } + } + + /** + * Destroys the chart.js object + * @returns True if a chart object has been destroyed + */ + protected destroyChart (): boolean { + if (this.chart) { + this.chart.destroy(); + this.chart = null; + return true; + } + return false; + } + + /** + * Update all data, title, scales, legends and re-render the chart based on its config + * @param updateMode Additional configuration for control an animation in the update process. + * @returns {void} + */ + public updateChart (updateMode: UpdateMode): void { + if (!this.chart || !this.config) { + return; + } + + this.chart.stop(); + this.mergeConfigs(); + this.requestUpdate(); + + this.chart?.update(updateMode); + } + + /** + * Template for title + * Rendered when `config.plugins.title.text` is set + * @returns Header template from title of config + */ + protected get titleTemplate (): TemplateResult | typeof nothing { + return this.chartTitle ? html`${this.chartTitle}` : nothing; + } + + /** + * A `TemplateResult` that will be used + * to render the updated internal template. + * @return Render template + */ + protected render (): TemplateResult { + return html` +
+ ${this.titleTemplate} +
+ +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ef-chart': Chart; + } +} diff --git a/packages/elements/src/chart/index.ts b/packages/elements/src/chart/index.ts index c12c9fa7f4..f156e8683d 100644 --- a/packages/elements/src/chart/index.ts +++ b/packages/elements/src/chart/index.ts @@ -1,607 +1,7 @@ -import { - BasicElement, - html, - css, - PropertyValues, - TemplateResult, - CSSResultGroup -} from '@refinitiv-ui/core'; -import { customElement } from '@refinitiv-ui/core/decorators/custom-element.js'; -import { property } from '@refinitiv-ui/core/decorators/property.js'; -import { query } from '@refinitiv-ui/core/decorators/query.js'; -import { VERSION } from '../version.js'; -import { color as parseColor } from '@refinitiv-ui/utils/color.js'; +// eslint-disable-next-line import/extensions +import { Chart } from 'chart.js/auto'; +import { doughnutCenterLabelPlugin } from './plugins/index.js'; -import { - ChartConfiguration, - ChartDataset, - Chart as ChartJS, - ChartOptions, - ChartType, - Color, - LegendItem, - LineControllerDatasetOptions, - Plugin, - UpdateMode - // TODO: import only common types and let user registers specific type - // eslint-disable-next-line import/extensions -} from 'chart.js/auto'; -// Register plugins -import doughnutCenterPlugin from './plugins/doughnut-center-label.js'; -import 'chartjs-adapter-date-fns'; +Chart.register(doughnutCenterLabelPlugin); -import { - merge, - MergeObject, - DatasetColors -} from './helpers/index.js'; - -import '../header/index.js'; - -const CSS_COLOR_PREFIX = '--chart-color-'; - -/* Make ChartJS to know our plugin - * https://www.chartjs.org/docs/latest/developers/plugins.html#typescript-typings - */ -declare module 'chart.js' { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface PluginOptionsByType { - 'ef-chart': object; - } -} - -/** - * Charting component that use ChartJS library - */ -@customElement('ef-chart') -export class Chart extends BasicElement { - - /** - * Element version number - * @returns version number - */ - static get version (): string { - return VERSION; - } - - /** - * Chart.js object - * @type {ChartJS | null} - */ - public chart: ChartJS | null = null; - - /** - * Chart configurations. Same configuration as ChartJS - * @type {ChartConfiguration} - */ - @property({ type: Object }) - public config: ChartConfiguration | null = null; - - /** - * Html canvas element - * @type {HTMLCanvasElement} - */ - @query('canvas') - protected canvas!: HTMLCanvasElement; - - /** - * Required properties, needed for chart to work correctly. - * @returns config - */ - protected get requiredConfig (): ChartOptions { - return { - responsive: true, - maintainAspectRatio: false, - plugins: { - title: { - display: false - } - } - }; - } - - /** - * List of available chart colors - * @type {string[]} - * @returns {string[]} List of available chart colors - */ - public get colors (): string[] { - let color; - let index = 0; - const colors = []; - while ((color = this.getComputedVariable(`${CSS_COLOR_PREFIX}${++index}`))) { - colors.push(color); - } - return colors; - } - - /** - * Returns the chart title - * @returns chart title - */ - protected get chartTitle (): string { - const title = this.config?.options?.plugins?.title?.text; - - if (title) { - return typeof title === 'string' ? title : title.join(); - } - - return ''; - } - - /** - * Returns a dataset array - * @returns dataset array - */ - protected get datasets (): ChartDataset[] { - return this.config?.data?.datasets || []; - } - - /** - * Invoked whenever the element is updated - * @param {PropertyValues} changedProperties Map of changed properties with old values - * @returns {void} - */ - protected updated (changedProperties: PropertyValues): void { - super.updated(changedProperties); - if (changedProperties.has('config')) { - this.onConfigChange(); - } - } - - /** - * Element connected - * @returns {void} - */ - public connectedCallback (): void { - super.connectedCallback(); - this.setGlobalConfig(); - if (this.canvas) { - this.createChart(); - } - } - - /** - * Element disconnected - * @returns {void} - */ - public disconnectedCallback (): void { - super.disconnectedCallback(); - this.destroyChart(); - } - - /** - * Create plugin to set our theme into chartjs lifecycle - * @returns {Plugin} plugin - */ - private createPlugin (): Plugin { - return { - id: 'ef-chart', - beforeInit: (chart: ChartJS) => { - const option: ChartOptions = this.themableChartOption; - merge(chart.config.options as ChartOptions, option); - }, - beforeUpdate: this.decorateColors - }; - } - - /** - * Themable parts of the config. - * This will be merged into the configuration object. - * @returns {ChartOptions} chart config with theme - */ - protected get themableChartOption (): ChartOptions { - const boxWidth = this.cssVarAsNumber('--legend-key-box-width', '10') as number; - let boxHeight = Number(getComputedStyle(this).getPropertyValue('font-size').replace('px', '')); - if (this.config?.options?.plugins?.legend?.labels?.usePointStyle) { - boxHeight = boxWidth; - } - - return { - animation: { - duration: this.cssVarAsNumber('--animation-duration', '0') - }, - elements: { - line: { - borderWidth: this.cssVarAsNumber('--line-width', '1'), - tension: this.cssVarAsNumber('--line-tension', '0.5') - } - }, - plugins: { - tooltip: { - backgroundColor: this.getComputedVariable('--tooltip-background-color', 'transparent'), - titleColor: this.getComputedVariable('--tooltip-title-color', 'transparent'), - bodyColor: this.getComputedVariable('--tooltip-body-color', 'transparent'), - cornerRadius: this.cssVarAsNumber('--tooltip-border-radius', '0'), - caretSize: this.cssVarAsNumber('--tooltip-caret-size', '0'), - padding: { - x: this.cssVarAsNumber('--tooltip-padding-x', '--tooltip-padding', '0'), - y: this.cssVarAsNumber('--tooltip-padding-y', '--tooltip-padding', '0') - }, - titleSpacing: this.cssVarAsNumber('--tooltip-title-spacing', '0'), - displayColors: false - }, - legend: { - position: ['pie', 'doughnut'].includes(this.config?.type || '') ? 'right' : 'top', - labels: { - boxWidth, - boxHeight, - generateLabels: this.generateLegendLabels - } - } - } - }; - } - - /** - * Set global configuration of ChartJS - * @returns {void} - */ - // TODO: Try and remove the need for global object modification. - // It's easier to cover all areas by modifying the global object, - // however, if possible, we should look to try and just modify local configs. - private setGlobalConfig (): void { - const cssStyle = getComputedStyle(this); - - // Set font globals - ChartJS.defaults.color = cssStyle.getPropertyValue('color'); - ChartJS.defaults.font.family = cssStyle.getPropertyValue('font-family'); - ChartJS.defaults.font.size = Number(cssStyle.getPropertyValue('font-size').replace('px', '')); - ChartJS.defaults.font.style = cssStyle.getPropertyValue('font-style') as 'normal' | 'italic' | 'oblique' | 'initial' | 'inherit' | undefined; - // Set global grid color - ChartJS.defaults.scale.grid.color = (line) => { - return line.index === 0 ? this.getComputedVariable('--zero-line-color', 'transparent') : this.getComputedVariable('--grid-line-color', 'transparent'); - }; - ChartJS.defaults.scales.radialLinear.ticks.showLabelBackdrop = false; - } - - /** - * Handles a change of configuration object. - * This does not fire when a property of the config object changes, - * for this use this.updateChart() to apply changes. - * @returns {void} - */ - protected onConfigChange (): void { - if (this.config) { - this.createChart(); - } - } - - /** - * Get as CSS variable and tries to convert it into a usable number - * @returns {(number|undefined)} The value as a number, or, undefined if NaN. - */ - protected cssVarAsNumber (...args: string[]): number | undefined { - const result = Number(this.getComputedVariable(...args).replace(/\D+$/, '')); - return isNaN(result) ? undefined : result; - } - - /** - * Inject theme color into each datasets - * @param {ChartJS} chart Chart.js instance - * @returns {void} - */ - protected decorateColors = (chart: ChartJS): void => { - chart.config.data.datasets.forEach((dataset, datasetIndex) => { - let colors; - let borderColor; - let backgroundColor; - const isMultipleDatasets = (chart.config.data.datasets.length > 1); - // From old requirement, Only line, radar, scatter, polarArea type are opaque backgroundColor - switch (dataset.type || this.config?.type) { - case 'line': - case 'radar': - case 'scatter': - colors = this.generateColors(false, 1, datasetIndex); - if (!dataset.borderColor) { - dataset.borderColor = colors.solid; - } - if (!dataset.backgroundColor) { - dataset.backgroundColor = colors.opaque; - } - if (!(dataset as LineControllerDatasetOptions).pointBackgroundColor) { - (dataset as LineControllerDatasetOptions).pointBackgroundColor = colors.solid; - } - if (!(dataset as LineControllerDatasetOptions).pointBorderColor) { - (dataset as LineControllerDatasetOptions).pointBorderColor = colors.solid; - } - break; - - // These types, Colors are set to an array. - case 'doughnut': - case 'pie': - case 'polarArea': - const index = isMultipleDatasets ? 0 : datasetIndex; - colors = this.generateColors(true, dataset.data ? dataset.data.length : 1, index); - borderColor = isMultipleDatasets ? this.getComputedVariable('--multi-dataset-border-color', '#fff') : colors.solid; - backgroundColor = this.config?.type === 'polarArea' ? colors.opaque : colors.solid; - if (!dataset.borderColor) { - dataset.borderColor = borderColor; - } - if (!dataset.backgroundColor) { - dataset.backgroundColor = backgroundColor; - } - // Add more colors if items aren't enough - if (Array.isArray(dataset.borderColor) && Array.isArray(borderColor) && dataset.borderColor.length < borderColor.length) { - merge(dataset.borderColor, borderColor); - } - if (Array.isArray(dataset.backgroundColor) && Array.isArray(backgroundColor) && dataset.backgroundColor.length < backgroundColor.length) { - merge(dataset.backgroundColor, backgroundColor); - } - break; - - // These types, Colors could be string or array - case 'bar': - case 'bubble': - colors = this.generateColors(!isMultipleDatasets, !isMultipleDatasets && dataset.data ? dataset.data.length : 1, datasetIndex); - borderColor = colors.solid; - backgroundColor = this.config?.type === 'bubble' ? colors.opaque : colors.solid; - if (!dataset.borderColor) { - dataset.borderColor = borderColor; - } - if (!dataset.backgroundColor) { - dataset.backgroundColor = backgroundColor; - } - // Add more colors if items aren't enough - if (Array.isArray(dataset.borderColor) && Array.isArray(borderColor) && dataset.borderColor.length < borderColor.length) { - merge(dataset.borderColor, borderColor); - } - - if (Array.isArray(dataset.backgroundColor) && Array.isArray(backgroundColor) && dataset.backgroundColor.length < backgroundColor.length) { - merge(dataset.backgroundColor, backgroundColor); - } - break; - // For other types - default: - colors = this.generateColors(false, dataset.data.length, datasetIndex); - if (!dataset.borderColor) { - dataset.borderColor = colors.solid; - } - if (!dataset.backgroundColor) { - dataset.backgroundColor = colors.solid; - } - break; - } - }); - }; - - /** - * Generates the legend labels on a given chart - * @param {ChartJS} chart Chart.js instance - * @returns {LegendItem[]} Array of label configurations - */ - protected generateLegendLabels = (chart: ChartJS): LegendItem[] => { - const chartType = (chart.config as ChartConfiguration).type; - if (!chartType) { - return []; - } - - let legends: LegendItem[] = []; - const datasets = chart.config.data.datasets; - - if ( - datasets.length - && chart?.config?.options?.plugins?.legend - && Array.isArray(datasets[0].backgroundColor) - ) { - - if (ChartJS.overrides.pie.plugins.legend.labels.generateLabels) { - legends = ChartJS.overrides.pie.plugins.legend.labels.generateLabels(chart); - } - - // Customize for doughnut chart change border color to background color - if (['pie', 'doughnut'].includes(chartType) && this.datasets.length > 1) { - legends.forEach((label: LegendItem) => { - label.strokeStyle = label.fillStyle; - }); - } - - return legends; - } - - if (ChartJS.defaults.plugins.legend.labels.generateLabels) { - legends = ChartJS.defaults.plugins.legend.labels.generateLabels(chart); - } - legends.forEach((legend, i) => { - legend.lineWidth = Number(datasets[i].borderWidth) || 0; - switch (datasets[i].type || chartType) { - case 'line': - case 'radar': - case 'bubble': - case 'polarArea': - legend.fillStyle = (datasets[i] as LineControllerDatasetOptions).borderColor as Color; - break; - // For other chart types - default: - break; - } - }); - return legends; - }; - - /** - * Merges all the different layers of the config. - * @returns {void} - */ - protected mergeConfigs (): void { - if (!this.config) { - return; - } - - let plugins: Plugin[] = [ - this.createPlugin(), - doughnutCenterPlugin - ]; - - if (Array.isArray(this.config.plugins) && this.config.plugins.length > 0) { - plugins = [ - ...plugins, - ...this.config.plugins - ]; - } - - merge(this.config as unknown as MergeObject, - { - plugins, - options: this.requiredConfig - } as MergeObject, - true - ); - } - - - /** - * Generates internal solid and opaque color set for a dataset - * @param {boolean} isArray Flag to return result in array or not e.g. doughnut, pie, etc - * @param {number} amount Amount of colors required - * @param {number} shift Positional shift of the color start point - * @returns {DatasetColors} Solid and opaque color values - */ - protected generateColors (isArray: boolean, amount: number, shift: number): DatasetColors { - const solid = []; - const opaque = []; - const alpha = Number(this.getComputedVariable('--fill-opacity', '0.2')); - - amount = isArray ? amount : 1; - - for (let i = shift; i < amount + shift; i++) { - const color = this.colors[i % this.colors.length]; - solid.push(color); - - const opaqueColor = parseColor(color); - if (opaqueColor) { - opaqueColor.opacity = alpha; - opaque.push(opaqueColor.toString()); - } - } - return { - solid: isArray ? solid : solid[0], - opaque: isArray ? opaque : opaque[0] - }; - } - - /** - * Creates a chart after config has changed, - * or, the element has been connected to the DOM - * @returns {void} - */ - protected createChart (): void { - const ctx = this.canvas.getContext('2d'); - if (ctx && this.config) { - this.destroyChart(); - this.mergeConfigs(); - - this.chart = new ChartJS(this.canvas, this.config); - } - } - - /** - * Destroys the chart.js object - * @returns True if a chart object has been destroyed - */ - protected destroyChart (): boolean { - if (this.chart) { - // Destroy the chart - this.chart.destroy(); - this.chart = null; - return true; - } - return false; - } - - /** - * Update all data, title, scales, legends and re-render the chart based on its config - * @param {UpdateMode} updateMode Additional configuration for control an animation in the update process. - * @returns {void} - */ - public updateChart (updateMode: UpdateMode): void { - if (!this.chart || !this.config) { - return; - } - - // Stop any chart.js animations - this.chart.stop(); - this.mergeConfigs(); - this.requestUpdate(); - - // Update the chart - this.chart?.update(updateMode); - } - - /** - * Template for title - * Rendered when `config.plugins.title.text` is set - * @returns Header template from title of config - */ - protected get titleTemplate (): TemplateResult | undefined { - return this.chartTitle - ? html`${this.chartTitle}` : undefined; - } - - /** - * A `CSSResultGroup` that will be used - * to style the host, slotted children - * and the internal template of the element. - * @return CSS template - */ - static get styles (): CSSResultGroup { - return css` - :host { - display: block; - overflow: hidden; - position: relative; - } - :host::before { - content: ''; - display: block; - padding-top: 60%; - min-height: 300px; - box-sizing: border-box; - } - [container] { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: flex; - flex-direction: column; - } - [part=chart] { - flex: 1 1 auto; - position: relative; - } - ef-header { - margin-bottom: 12px; - } - canvas { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - } - `; - } - - /** - * A `TemplateResult` that will be used - * to render the updated internal template. - * @return Render template - */ - protected render (): TemplateResult { - return html` -
- ${this.titleTemplate} -
- -
-
`; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'ef-chart': Chart; - } -} +export * from './elements/chart.js'; diff --git a/packages/elements/src/chart/plugins/doughnut-center-label.ts b/packages/elements/src/chart/plugins/doughnut-center-label.ts index c6c34dd8ef..231095b8bd 100644 --- a/packages/elements/src/chart/plugins/doughnut-center-label.ts +++ b/packages/elements/src/chart/plugins/doughnut-center-label.ts @@ -1,5 +1,7 @@ import { - Chart as ChartJS, + Chart as ChartJS +} from 'chart.js'; +import type { Plugin, ChartEvent, ChartType, @@ -280,4 +282,4 @@ const plugins: Plugin = { } }; -export default plugins; +export { plugins as doughnutCenterLabelPlugin }; diff --git a/packages/elements/src/chart/plugins/index.ts b/packages/elements/src/chart/plugins/index.ts new file mode 100644 index 0000000000..5b04797759 --- /dev/null +++ b/packages/elements/src/chart/plugins/index.ts @@ -0,0 +1 @@ +export * from './doughnut-center-label.js';