diff --git a/package-lock.json b/package-lock.json index 018ea41b6..f4fe50746 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3749,6 +3749,14 @@ "@babel/types": "^7.3.0" } }, + "@types/chart.js": { + "version": "2.9.31", + "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.31.tgz", + "integrity": "sha512-hzS6phN/kx3jClk3iYqEHNnYIRSi4RZrIGJ8CDLjgatpHoftCezvC44uqB3o3OUm9ftU1m7sHG8+RLyPTlACrA==", + "requires": { + "moment": "^2.10.2" + } + }, "@types/cordova": { "version": "0.0.34", "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", @@ -5930,6 +5938,47 @@ "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" }, + "chart.js": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz", + "integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==", + "requires": { + "chartjs-color": "^2.1.0", + "moment": "^2.10.2" + } + }, + "chartjs-color": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz", + "integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==", + "requires": { + "chartjs-color-string": "^0.6.0", + "color-convert": "^1.9.3" + }, + "dependencies": { + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + } + } + }, + "chartjs-color-string": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz", + "integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==", + "requires": { + "color-name": "^1.0.0" + } + }, "check-es-compat": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/check-es-compat/-/check-es-compat-1.1.1.tgz", diff --git a/package.json b/package.json index c978d7314..606761052 100644 --- a/package.json +++ b/package.json @@ -68,9 +68,11 @@ "@ionic/angular": "^5.6.3", "@ngx-translate/core": "^13.0.0", "@ngx-translate/http-loader": "^6.0.0", + "@types/chart.js": "^2.9.31", "@types/cordova": "0.0.34", "@types/cordova-plugin-file-transfer": "^1.6.2", "@types/dom-mediacapture-record": "^1.0.7", + "chart.js": "^2.9.4", "com-darryncampbell-cordova-plugin-intent": "^1.3.0", "cordova": "^10.0.0", "cordova-android": "^8.1.0", diff --git a/src/core/components/chart/chart.scss b/src/core/components/chart/chart.scss new file mode 100644 index 000000000..b3f3f186a --- /dev/null +++ b/src/core/components/chart/chart.scss @@ -0,0 +1,8 @@ +:host { + display: block; + + canvas { + max-width: 500px; + margin: 0 auto; + } +} diff --git a/src/core/components/chart/chart.ts b/src/core/components/chart/chart.ts new file mode 100644 index 000000000..5021bd313 --- /dev/null +++ b/src/core/components/chart/chart.ts @@ -0,0 +1,189 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnDestroy, OnInit, ElementRef, OnChanges, ViewChild, SimpleChange } from '@angular/core'; +import { CoreFilter } from '@features/filter/services/filter'; +import { CoreFilterHelper } from '@features/filter/services/filter-helper'; +import { CoreUtils } from '@services/utils/utils'; +import { Chart, ChartLegendLabelItem, ChartLegendOptions } from 'chart.js'; + +/** + * This component shows a chart using chart.js. + * Documentation can be found at http://www.chartjs.org/docs/. + * It only supports changes on these properties: data and labels. + * + * Example usage: + * + */ +@Component({ + selector: 'core-chart', + templateUrl: 'core-chart.html', + styleUrls: ['chart.scss'], +}) +export class CoreChartComponent implements OnDestroy, OnInit, OnChanges { + + // The first 6 colors will be the app colors, the following will be randomly generated. + // It will use the same colors in the whole session. + protected static backgroundColors = [ + 'rgba(0,100,210, 0.6)', + 'rgba(203,61,77, 0.6)', + 'rgba(0,121,130, 0.6)', + 'rgba(249,128,18, 0.6)', + 'rgba(94,129,0, 0.6)', + 'rgba(251,173,26, 0.6)', + ]; + + @Input() data: number[] = []; // Chart data. + @Input() labels: string[] = []; // Labels of the data. + @Input() type?: string; // Type of chart. + @Input() legend?: ChartLegendOptions; // Legend options. + @Input() height = 300; // Height of the chart element. + @Input() filter?: boolean | string; // Whether to filter labels. If not defined, true if contextLevel and instanceId are set. + @Input() contextLevel?: string; // The context level of the text. + @Input() contextInstanceId?: number; // The instance ID related to the context. + @Input() courseId?: number; // Course ID the text belongs to. It can be used to improve performance with filters. + @Input() wsNotFiltered?: boolean | string; // If true it means the WS didn't filter the labels for some reason. + @ViewChild('canvas') canvas?: ElementRef; + + chart?: ChartWithLegend; + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + let legend: ChartLegendOptions = {}; + if (typeof this.legend == 'undefined') { + legend = { + display: false, + labels: { + generateLabels: (chart: Chart): ChartLegendLabelItem[] => { + const data = chart.data; + if (data.labels?.length) { + const datasets = data.datasets![0]; + + return data.labels.map((label, i) => ({ + text: label + ': ' + datasets.data![i], + fillStyle: datasets.backgroundColor![i], + })); + } + + return []; + }, + }, + }; + } else { + legend = Object.assign({}, this.legend); + } + + if (this.type == 'bar' && this.data.length >= 5) { + this.type = 'horizontalBar'; + } + + // Format labels if needed. + await this.formatLabels(); + + const context = this.canvas!.nativeElement.getContext('2d')!; + this.chart = new Chart(context, { + type: this.type, + data: { + labels: this.labels, + datasets: [{ + data: this.data, + backgroundColor: this.getRandomColors(this.data.length), + }], + }, + options: { legend }, + }); + } + + /** + * @inheritdoc + */ + async ngOnChanges(changes: Record): Promise { + if (!this.chart || !changes.labels || !changes.data) { + return; + } + + if (changes.labels) { + // Format labels if needed. + await this.formatLabels(); + } + + this.chart.data.datasets![0] = { + data: this.data, + backgroundColor: this.getRandomColors(this.data.length), + }; + this.chart.data.labels = this.labels; + this.chart.update(); + } + + /** + * Format labels if needed. + * + * @return Promise resolved when done. + */ + protected async formatLabels(): Promise { + if (!this.contextLevel || !this.contextInstanceId || this.filter === false) { + return; + } + + const options = { + clean: true, + singleLine: true, + courseId: this.courseId, + wsNotFiltered: CoreUtils.isTrueOrOne(this.wsNotFiltered), + }; + + const filters = await CoreFilterHelper.getFilters(this.contextLevel, this.contextInstanceId, options); + + await Promise.all(this.labels.map(async (label, i) => { + this.labels[i] = await CoreFilter.formatText(label, options, filters); + })); + } + + /** + * Generate random colors if needed. + * + * @param n Number of colors needed. + * @return Array with the number of background colors requested. + */ + protected getRandomColors(n: number): string[] { + while (CoreChartComponent.backgroundColors.length < n) { + const red = Math.floor(Math.random() * 255); + const green = Math.floor(Math.random() * 255); + const blue = Math.floor(Math.random() * 255); + CoreChartComponent.backgroundColors.push('rgba(' + red + ', ' + green + ', ' + blue + ', 0.6)'); + } + + return CoreChartComponent.backgroundColors.slice(0, n); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + if (this.chart) { + this.chart.destroy(); + this.chart = undefined; + } + } + +} + +// For some reason the legend property isn't defined in TS, define it ourselves. +type ChartWithLegend = Chart & { + legend?: { + legendItems?: ChartLegendLabelItem[]; + }; +}; diff --git a/src/core/components/chart/core-chart.html b/src/core/components/chart/core-chart.html new file mode 100644 index 000000000..79c617a77 --- /dev/null +++ b/src/core/components/chart/core-chart.html @@ -0,0 +1,8 @@ + + + + + + {{data.text}} + + diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 8e1f2bfe6..e6f72957f 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -53,6 +53,7 @@ import { CoreFilesComponent } from './files/files'; import { CoreLocalFileComponent } from './local-file/local-file'; import { CoreBSTooltipComponent } from './bs-tooltip/bs-tooltip'; import { CoreSitePickerComponent } from './site-picker/site-picker'; +import { CoreChartComponent } from './chart/chart'; @NgModule({ declarations: [ @@ -88,6 +89,7 @@ import { CoreSitePickerComponent } from './site-picker/site-picker'; CoreLocalFileComponent, CoreBSTooltipComponent, CoreSitePickerComponent, + CoreChartComponent, ], imports: [ CommonModule, @@ -130,6 +132,7 @@ import { CoreSitePickerComponent } from './site-picker/site-picker'; CoreLocalFileComponent, CoreBSTooltipComponent, CoreSitePickerComponent, + CoreChartComponent, ], }) export class CoreComponentsModule {}