I'm building an expense manager application where I have a stats tab containing two subtabs, expenses and incomes each of which has its own chart instance. Rendering is first slow, it takes some seconds for the chart in an animated way at the initialization of the tabs, second, chart is becoming undefined when going back and forth between tabs so it's not appearing lets say for the expenses tab, im getting errors like : Chart with id "0" has to be deleted before using chart with id "myChart1" (although I don't know where is chart id 0 coming from) and getting sometimes an error like (can't read properties of null: reading id 'myChart1'). I tried using ngAfterViewInit first but it didn't work, then i tried using a try catch block and it kind of fixed the issue but it still is there unexpectedly. Then i added a timeout function to retry the chart creation since it could've been that chart component in HTML is not rendered properly already, so give it another chance, but still I get the bugs I told you about before. So what's the best way to write the code below ?
<ion-content [fullscreen]="true">
@if (!loading){
<div style="position: relative; height: 100%;" id="expense-chart" *ngIf="!noData">
<div id="chart">
<ion-title class="chart-title">Expenses:</ion-title>
<canvas id="myChart1" style="height: 20%; width: 20%;"></canvas>
</div>
</div>
}
@else {
<ion-spinner name="crescent" id="spinner" style="--width: 500px; --height: 500px;"></ion-spinner>
}
@if (!loading){
<div class="percentages" *ngIf="!noData">
<ion-title class="chart-title">Percentages:</ion-title>
<div class="percentages-container">
<div *ngFor="let pair of expensePercentages; let i = index" class="percentage">
<ion-label id="category">
{{pair['category']}}:</ion-label>
<ion-label>{{pair['percentage'] | percent}}</ion-label>
</div>
</div>
</div>
}
<div *ngIf="noData" id="no-data">
<div class="no-data">
<ion-title>No data available</ion-title>
</div>
</div>
</ion-content>
import { AfterViewInit, Component, createComponent, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ArcElement, CategoryScale, Chart, DoughnutController, Legend, LinearScale, PieController, registerables, Title, Tooltip } from 'chart.js';
import { FirestoreService } from '../firestore.service';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { BudgetService } from '../budget.service';
import {IonicModule} from '@ionic/angular';
Chart.register(ArcElement, Tooltip, Legend, Title, CategoryScale, LinearScale, DoughnutController, PieController);
@Component({
selector: 'app-expenses-stats',
templateUrl: './expenses-stats.page.html',
styleUrls: ['./expenses-stats.page.scss'],
standalone: true,
imports: [IonicModule, CommonModule, FormsModule]
})
export class ExpensesStatsPage implements OnInit, OnDestroy, AfterViewInit{
noData: boolean = false;
loading: boolean = true;
labels: string[] = [];
selectedMonth$!: Observable<number>;
selectedMonth: number = new Date().getMonth();
changedBudget$!: Observable<'Expense' | 'Income' | null>;
expensePercentages!: {category: string, percentage: number}[] ;
public myChart1: any;
constructor(private firestoreService: FirestoreService, private route: ActivatedRoute,
private budgetService: BudgetService
) {
this.changedBudget$ = budgetService.changedBudget$;
this.selectedMonth$ = firestoreService.month$;
this.selectedMonth$.subscribe(month => {
this.selectedMonth = month;
this.createChart();
});
this.changedBudget$.subscribe(type => {
if (type === 'Expense') {
this.createChart();
}
});
}
ngOnInit() {
// this.createChart();
}
ngAfterViewInit(): void {
this.createChart();
}
ngOnDestroy(): void {
if (this.myChart1) {
this.myChart1.destroy();
this.myChart1 = null;
}
}
async createChart() {
this.loading = true;
this.noData = false;
if (this.myChart1) {
this.myChart1.destroy();
this.myChart1 = null;
}
const uid = localStorage.getItem('userId')!;
this.labels = await this.firestoreService.getCategories('Expense');
const data = await this.firestoreService.getExpenseData(uid, this.selectedMonth);
if (Object.keys(data).length === 0) {
this.noData = true;
this.loading = false;
return;
}
let arrayData = [];
let total = 0;
arrayData = this.labels.map((label) => {
const value = data[label] || 0;
total += value;
return value;
});
console.log("Array Data: ", arrayData);
this.expensePercentages = arrayData.map((value, index) => {
return {
category: this.labels[index],
percentage: (value / total)
};
});
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
// Format the tooltip label to include the '$' symbol
label: function(tooltipItem: any) {
console.log("Tooltip itemraw: ", tooltipItem.raw);
return '$' + tooltipItem.raw.toFixed(2); // Use toFixed to show two decimal places
}
}
}
},
layout: {
padding: {
top: 0,
bottom: 0,
left: 0,
right: 0,
},
},
};
const chartData = {
labels: this.labels,
datasets: [{
data: arrayData,
backgroundColor: this.generateHexColors(this.labels.length)
}]
};
try {
setTimeout(() => {
this.myChart1 = new Chart('myChart1', {
type: 'pie',
data: chartData,
options: options
});
},500);
} catch (error) {
this.createChart();
}
this.loading = false;
}
generateHexColors(n: number): string[] {
const colors: string[] = [];
const step = Math.floor(0xffffff / n); // Ensure colors are spaced evenly
for (let i = 0; i < n; i++) {
const colorValue = (step * i) % 0xffffff; // Calculate the color value
const hexColor = `#${colorValue.toString(16).padStart(6, '0')}`;
colors.push(hexColor);
}
return colors;
}
}
Swap out the @if
and *ngIf
with [hidden]
so that the HTML is never destroyed and just hidden (rendering wont take time). Also access the HTML element using ViewChild
instead of traditional methods.
<ion-content [fullscreen]="true">
<div style="position: relative; height: 100%;" id="expense-chart" [hidden]="noData && loading">
<div id="chart">
<ion-title class="chart-title">Expenses:</ion-title>
<canvas id="myChart1" style="height: 20%; width: 20%;" #chart></canvas> <!-- notice this! -->
</div>
</div>
<ion-spinner name="crescent" id="spinner" style="--width: 500px; --height: 500px;" [hidden]="!(noData && loading)"></ion-spinner>
<div class="percentages" [hidden]="noData && loading">
<ion-title class="chart-title">Percentages:</ion-title>
<div class="percentages-container">
<div *ngFor="let pair of expensePercentages; let i = index" class="percentage">
<ion-label id="category">
{{pair['category']}}:</ion-label>
<ion-label>{{pair['percentage'] | percent}}</ion-label>
</div>
</div>
</div>
<div [hidden]="!noData" id="no-data">
<div class="no-data">
<ion-title>No data available</ion-title>
</div>
</div>
</ion-content>
Then if you are having multiple calls to createChart
it will cause the same code to run again and again, instead initialize all the code on ngAfterViewInit
, if you see the code, we merge all triggers using combineLatest
of rxjs. I have used debounceTime
to reduce the number of calls to initialize the chart.
import {
AfterViewInit,
Component,
createComponent,
ElementRef,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
ArcElement,
CategoryScale,
Chart,
DoughnutController,
Legend,
LinearScale,
PieController,
registerables,
Title,
Tooltip,
} from 'chart.js';
import { FirestoreService } from '../firestore.service';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { BudgetService } from '../budget.service';
import { IonicModule } from '@ionic/angular';
Chart.register(
ArcElement,
Tooltip,
Legend,
Title,
CategoryScale,
LinearScale,
DoughnutController,
PieController
);
@Component({
selector: 'app-expenses-stats',
templateUrl: './expenses-stats.page.html',
styleUrls: ['./expenses-stats.page.scss'],
standalone: true,
imports: [IonicModule, CommonModule, FormsModule],
})
export class ExpensesStatsPage implements OnInit, OnDestroy, AfterViewInit {
@ViewChild('chart') chart!: ElementRef<any>;
noData: boolean = false;
loading: boolean = true;
labels: string[] = [];
selectedMonth$!: Observable<number>;
selectedMonth: number = new Date().getMonth();
changedBudget$!: Observable<'Expense' | 'Income' | null>;
expensePercentages!: { category: string; percentage: number }[];
public myChart1: any;
constructor(
private firestoreService: FirestoreService,
private route: ActivatedRoute,
private budgetService: BudgetService
) {
this.changedBudget$ = budgetService.changedBudget$;
this.selectedMonth$ = firestoreService.month$;
}
ngOnInit() {
// this.createChart();
}
ngAfterViewInit(): void {
combineLatest([
this.selectedMonth$.pipe(
tap((month: any) => {
this.selectedMonth = month;
this.createChart();
})
),
this.changedBudget$.pipe(filter((type: any) => type === 'Expense')),
])
.pipe(
debounceTime(500) // reduce the number of calls - optional
)
.subscribe(() => {
this.createChart();
});
}
ngOnDestroy(): void {
if (this.myChart1) {
this.myChart1.destroy();
this.myChart1 = null;
}
}
async createChart() {
this.loading = true;
this.noData = false;
if (this.myChart1) {
this.myChart1.destroy();
this.myChart1 = null;
}
const uid = localStorage.getItem('userId')!;
this.labels = await this.firestoreService.getCategories('Expense');
const data = await this.firestoreService.getExpenseData(
uid,
this.selectedMonth
);
if (Object.keys(data).length === 0) {
this.noData = true;
this.loading = false;
return;
}
let arrayData = [];
let total = 0;
arrayData = this.labels.map((label) => {
const value = data[label] || 0;
total += value;
return value;
});
console.log('Array Data: ', arrayData);
this.expensePercentages = arrayData.map((value, index) => {
return {
category: this.labels[index],
percentage: value / total,
};
});
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
// Format the tooltip label to include the '$' symbol
label: function (tooltipItem: any) {
console.log('Tooltip itemraw: ', tooltipItem.raw);
return '$' + tooltipItem.raw.toFixed(2); // Use toFixed to show two decimal places
},
},
},
},
layout: {
padding: {
top: 0,
bottom: 0,
left: 0,
right: 0,
},
},
};
const chartData = {
labels: this.labels,
datasets: [
{
data: arrayData,
backgroundColor: this.generateHexColors(this.labels.length),
},
],
};
this.myChart1 = new Chart(this.chart.nativeElement, {
type: 'pie',
data: chartData,
options: options,
});
this.loading = false;
}
generateHexColors(n: number): string[] {
const colors: string[] = [];
const step = Math.floor(0xffffff / n); // Ensure colors are spaced evenly
for (let i = 0; i < n; i++) {
const colorValue = (step * i) % 0xffffff; // Calculate the color value
const hexColor = `#${colorValue.toString(16).padStart(6, '0')}`;
colors.push(hexColor);
}
return colors;
}
}