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 {}