I have a problem in my Angular v18 web app. I have various component (dashboards in the specific) containing apache echart like stacked lines, bar chart, gauge and so on. When i navigate through pages i get the following warning in console
I tried to use the method dispose into the standalone components but when navigate to another page and go back to it the message appears in the console, like the previous instance is still there. Here the code of the echart component:
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.8.3/angular.min.js">
import { Component, ElementRef, Input, OnChanges, SimpleChanges, AfterViewInit, OnInit, Output, EventEmitter } from '@angular/core';
import { EChartsOption } from 'echarts';
import { NgxEchartsModule } from 'ngx-echarts';
import * as echarts from 'echarts';
import { NgxEchartsDirective, provideEcharts } from 'ngx-echarts';
import { ColorService } from '../../services/color.service';
import { CommonModule } from '@angular/common';
import { Series } from '../../services/models/lineChartTimeSeriesDTO';
@Component({
selector: 'app-line-chart',
standalone: true,
imports: [
NgxEchartsDirective,
NgxEchartsModule,
CommonModule
],
providers: [provideEcharts()],
templateUrl: './line-chart.component.html',
styleUrl: './line-chart.component.css'
})
export class LineChartComponent implements OnChanges, AfterViewInit, OnInit {
@Output() chartReady = new EventEmitter<echarts.ECharts>();
@Input() data: Series[] = [{
name: 'default',
type: 'line',
data: [],
smooth: false,
showSymbol: false,
emphasis: {
disabled: true
}
}];
@Input() labels: string[] = [];
@Input() title: string = "";
@Input() unit: string = "";
@Input() seriesName: string = "";
@Input() legendData: string[] = [];
@Input() chartHeight: number = 400;
@Input() headerTitle: string = "";
@Input() multiplicativeFactor: number = 1;
@Input() rotateLabelXAxis: number = 0;
@Input() showSeconds: boolean = false;
chartOptions: EChartsOption = {};
chartInstance: any = null;
constructor(private el: ElementRef, private colorService: ColorService) {
this.chartOptions = this.getChartOptions();
}
ngOnInit(): void {
}
ngAfterViewInit() {
const container = this.el.nativeElement.querySelector('.chart-container-line');
if (!container) {
return;
}
const existingInstance = echarts.getInstanceByDom(container);
if (existingInstance && this.chartInstance != null) {
existingInstance.dispose();
}
container.removeAttribute('_echarts_instance_');
container.style.height = `${this.chartHeight}px`;
container.style.width = '100%';
requestAnimationFrame(() => {
this.initChart(container);
this.chartReady.emit(this.chartInstance);
});
}
private initChart(container: HTMLElement) {
// Check again if an instance already exists
const existingInstance = echarts.getInstanceByDom(container);
if (existingInstance && this.chartInstance != null) {
existingInstance.dispose();
}
// MANUALLY REMOVE ANY EXISTING REFERENCES
container.removeAttribute('_echarts_instance_');
this.chartInstance = echarts.init(container);
this.chartInstance.setOption(this.chartOptions);
this.chartInstance.on('brushEnd', (params: any) => this.onChartInit(params));
/*
setTimeout(() => {
this.chartReady = true;
}, 500);
*/
// Resize chart on window resize event
window.addEventListener('resize', () => {
if (this.chartInstance) {
this.chartInstance.resize();
}
});
}
ngOnChanges(changes: SimpleChanges): void {
/* refresh chart on data change */
if (changes['title'] || changes['unit'] || changes['data']) {
this.updateChart();
}
}
onChartInit(params: any) {
/* brush events */
if (params.areas && params.areas.length > 0) {
const selectedArea = params.areas[0];
const coordRange = selectedArea.coordRange;
if (coordRange) {
const [start, end] = coordRange;
/* Update x interval */
this.updateXAxisRange(start, end);
/* Remove selection */
this.chartInstance.dispatchAction({
type: 'brush',
areas: []
});
}
}
}
private updateXAxisRange(start: number, end: number) {
/* edit the visible range on x axis */
this.chartInstance.setOption({
xAxis: {
min: start,
max: end
}
});
}
private getChartOptions(): EChartsOption {
const maxValue = this.findFullScale(this.data);
return {
animation: false,
title: {
text: this.title,
left: "center",
textStyle: {
color: this.colorService.getColorFromCSSVariable("--whiteStandard")
},
},
legend: {
data: this.legendData,
type: 'scroll',
textStyle: {
color: this.colorService.getColorFromCSSVariable("--whiteStandard")
},
pageIconColor: this.colorService.getColorFromCSSVariable("--whiteStandard"),
pageIconInactiveColor: this.colorService.getColorFromCSSVariable("--lightGrey"),
pageTextStyle: {
color: this.colorService.getColorFromCSSVariable("--whiteStandard")
}
},
tooltip: {
show: true,
trigger: 'axis',
formatter: (params: any) => {
const timestamp = this.formatDate(params[0].axisValueLabel, this.showSeconds)
let dataValues = params.map((item: any) => {
return `${item.marker} ${item.seriesName} <strong>${this.formatValue(item.value[1], maxValue, this.multiplicativeFactor)} ${this.measureUnitFormatter(maxValue, this.unit)}</strong>`
}).join('<br/>');
return `${timestamp}<br/>${dataValues}`;
}
},
backgroundColor: this.colorService.getColorFromCSSVariable("--darkThemeCardAlfagreen"),
toolbox: {
right: 10,
top: 25,
feature: {
restore: {},
brush: {
type: ['lineX']
}
},
iconStyle: {
borderColor: this.colorService.getColorFromCSSVariable("--whiteStandard")
}
},
grid: {
/* avoid axis label from cut */
containLabel: true,
/* auto fit margins dimensions */
left: '1.5%',
right: '1%',
top: '20%',
bottom: '10%'
},
textStyle: {
color: this.colorService.getColorFromCSSVariable("--whiteStandard")
},
xAxis: [{
type: 'time',
axisLabel: {
rotate: this.rotateLabelXAxis,
hideOverlap: true,
formatter: (value) => {
return this.formatDate(value, this.showSeconds);
}
},
name: "data e ora",
nameLocation: 'middle',
nameTextStyle: {
/* space between axis and labels */
padding: 30,
fontSize: 15,
fontWeight: 'bold'
},
splitLine: {
/* add grid lines */
show: false,
lineStyle: {
color: this.colorService.getColorFromCSSVariable("--whiteStandard"),
type: 'solid'
}
}
}],
yAxis: {
type: 'value',
name: this.measureUnitFormatter(maxValue, this.unit),
nameLocation: 'end',
nameTextStyle: {
/* space between axis and labels */
padding: 5,
fontSize: 12
},
splitLine: {
show: true,
lineStyle: {
color: this.colorService.getColorFromCSSVariable("--whiteStandard"),
type: 'solid'
}
},
axisLabel: {
formatter: (value: number) => {
return this.formatValue(value, maxValue, this.multiplicativeFactor);
}
}
},
series: this.data,
brush: {
/* enable rectangle selection */
toolbox: ['lineX'],
brushMode: 'single',
xAxisIndex: 0,
throttleType: 'fixRate',
brushType: 'lineX',
removeOnClick: true,
transformable: false
} as any,
graphic: {
type: 'text',
left: 'center',
top: 'middle',
style: {
text: this.data != null && this.data.length > 0 && this.data.every(series => series.data.length === 0) ? 'No data' : '',
fontSize: 30,
fill: this.colorService.getColorFromCSSVariable("--whiteStandard")
}
}
};
}
private updateChart() {
if (this.chartInstance) {
this.chartInstance.setOption(this.getChartOptions(), { notMerge: true });
//this.chartInstance.resize();
}
}
findFullScale(data: Series[]): number {
let overallMax = -Infinity;
/* Iterate over every series */
for (const series of data) {
// Find max
const seriesMax = Math.max(...series.data.map(arr => arr[1] * this.multiplicativeFactor));
overallMax = Math.max(overallMax, seriesMax);
}
return overallMax;
}
measureUnitFormatter(maxValue: number, unitMeasure: string): string {
if (maxValue >= 1000000000) {
return 'G' + unitMeasure;
} else if (maxValue >= 1000000) {
return 'M' + unitMeasure;
} else if (maxValue >= 1000) {
return 'k' + unitMeasure;
}
return unitMeasure;
}
formatValue(value: number, maxValue: number, multiplicativeFactor: number): string {
const realValue = value * multiplicativeFactor;
if (maxValue >= 1000000000) {
return (realValue / 1000000000).toFixed(1);
} else if (maxValue >= 1000000) {
return (realValue / 1000000).toFixed(1);
/* show K to scale number on axis */
} else if (maxValue >= 1000) {
return (realValue / 1000).toFixed(1);
}
return (realValue).toString();
}
formatDate(value: any, showSeconds: boolean): string {
const date = new Date(value);
// Day
const day = String(date.getDate()).padStart(2, '0');
// Month
const month = String(date.getMonth() + 1).padStart(2, '0');
// Year
const year = date.getFullYear();
// Hour, minutes, seconds
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
if (showSeconds) {
// Formatted string with \n
return `${day}/${month}/${year}\n${hours}:${minutes}:${seconds}`;
} else {
// Formatted string with \n
return `${day}/${month}/${year}\n${hours}:${minutes}`;
}
}
ngOnDestroy() {
/* clean memory after componenst is destroied */
if (this.chartInstance) {
this.chartInstance.dispose();
this.chartInstance = null;
}
}
}</script>
And here the typescript code of the dashboard where the component is called:
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.8.3/angular.min.js">
import { AfterViewInit, ChangeDetectorRef, Component, OnDestroy, ViewChild } from '@angular/core';
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
import { Router, RouterModule } from '@angular/router';
import { OnInit } from '@angular/core';
import { LineChartComponent } from '../../components/line-chart/line-chart.component';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { CommonModule } from '@angular/common';
import { ModalComponent } from '../../components/modal/modal.component';
import { GeoMapChartComponent } from '../../components/geo-map-chart/geo-map-chart.component';
import { ContainerTemplateComponent } from '../../templates/container-template/container-template.component';
import { NumericSummaryComponent } from "../../components/numeric-summary/numeric-summary.component";
import { PlantService } from '../../services/landingBoard/plant.service';
import { PlantResponse } from '../../services/models/landingBoardDTO';
import { Series } from '../../services/models/lineChartTimeSeriesDTO';
import { LineChartService } from '../../services/charts/lineChart.service';
import { GeoPoints } from '../../services/models/geomapChartDTO';
import { GeoMapService } from '../../services/charts/geomapChart.service';
import { LoaderComponent } from '../../templates/loader/loader.component';
import { Subscription } from 'rxjs';
import { AlarmsService } from '../../services/alarms/alarms.service';
import { ToastService } from '../../services/toast.services';
@Component({
selector: 'app-landing',
standalone: true,
imports: [
RouterModule,
NgbAccordionModule,
RouterModule,
NgbModule,
CommonModule,
ModalComponent,
GeoMapChartComponent,
LineChartComponent,
ContainerTemplateComponent,
NumericSummaryComponent,
LoaderComponent,
],
templateUrl: './landing.component.html',
styleUrl: './landing.component.css'
})
export class LandingComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('myModal') myModal!: ModalComponent;
plantData: PlantResponse[] = [];
latitude: string = "";
longitude: string = "";
dailyPowerData: Series[] = [];
measureUnit: string = "";
modal_messages!: any;
legendData: string[] = [];
geoPoints: GeoPoints[] = [];
coordinatesExchange: GeoPoints[] = [];
dynamicHeight: string = '100px';
animationType: 'progress' | 'progress-dark' | 'pulse' | false = 'progress-dark';
chartLoaded: boolean = false;
plantDataSubscription: Subscription | undefined;
plantGeoPointsSubscription: Subscription | undefined;
plantDailyPowerDataSubscription: Subscription | undefined;
alarmsDataSubscription: Subscription | undefined;
totalPagesModal: number = 1;
constructor(private plantDataService: PlantService, private lineChartService: LineChartService, private geoMapService: GeoMapService, private cdRef: ChangeDetectorRef, private router: Router, private alarmsService: AlarmsService, private toastService: ToastService) { }
ngOnInit(): void {
/* Load plant data */
this.loadPlantData();
}
ngAfterViewInit() {
/* Stop loader animation after a certain time a db is unreachable */
setTimeout(() => {
this.animationType = false;
}, 1000);
}
modalOnClick(dataSource: string, clusterId: number, clusterName: string) {
this.loadAlarmsDataForModal(dataSource, clusterId, clusterName);
}
/* Load alarms data for modal from API services */
private loadAlarmsDataForModal(dataSource: string, clusterId: number, clusterName: string): void {
this.alarmsDataSubscription = this.alarmsService.getUnresolvedAlarms(dataSource, clusterId)
.subscribe({
next: (data) => {
this.modal_messages = data;
//console.log('data for modal: ' + JSON.stringify(this.modal_messages))
this.totalPagesModal = Math.ceil(this.modal_messages.payload.length / 6);
this.myModal.modalOnClick(clusterName);
},
error: (error) => {
this.modal_messages = [];
this.toastService.errorStickyToast("Errore", "Problema durante il recupero degli allarmi");
}
});
}
/* Load data methods from API services */
private loadPlantData(): void {
this.plantDataSubscription = this.plantDataService.getPlantData()
.subscribe({
next: (data) => {
this.plantData = data;
data.forEach(item => {
const payload = item?.payload;
if (payload?.numericSummary?.empty) {
this.toastService.warningAutoDismissToast('Attenzione', `Nessun dato trovato per ${payload.plant.name}. I meter probabilmente sono offline.`);
}
});
},
error: (error) => {
this.plantData = [];
this.toastService.errorStickyToast('Errore', "Code error: " + error.status + ". Message: " + error.statusText);
}
});
}
private loadGeoPointsPlantData(): void {
this.plantGeoPointsSubscription = this.plantDataService.getPlantGeoPoints()
.subscribe({
next: (data) => {
this.geoPoints = [
{
name: '',
coord: [0, 0],
dataSource: '',
description: '',
address: ''
}
];
setTimeout(() => {
this.geoPoints = [...this.geoMapService.extractPlantGeoData(data)];
}, 500);
},
error: (error) => {
this.geoPoints = [];
}
});
}
private loadDailyPowerPlantData(): void {
this.plantDailyPowerDataSubscription = this.plantDataService.getPlantChartData()
.subscribe({
next: (data) => {
this.dailyPowerData = this.lineChartService.extractLineChartSeries(data);
this.legendData = this.lineChartService.extractLineChartSeriesNames(this.dailyPowerData);
},
error: (error) => {
this.dailyPowerData = [];
this.legendData = [];
}
});
}
onChartReady(chart: echarts.ECharts) {
this.loadDailyPowerPlantData();
}
geoChartReady(chart: echarts.ECharts) {
this.loadGeoPointsPlantData();
}
ngOnDestroy(): void {
this.plantDataSubscription?.unsubscribe();
this.plantGeoPointsSubscription?.unsubscribe();
this.plantDailyPowerDataSubscription?.unsubscribe();
this.alarmsDataSubscription?.unsubscribe();
}
navigateToCompanyPlant(dataSource: string, id: number) {
this.router.navigate(['/companyPlant'], { queryParams: { dataSource: dataSource, idLevelB: id } });
}
}</script>
<div class="wrapper">
<div class="main-panel">
<div class="main-header">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-3">
<div class="container-fluid">
<a class="navbar-brandCustom me-2 mb-1 d-flex align-items-center">
<img src="logo/alfagreen_logo.png" alt="Logo" [routerLink]="['/landing']">
</a>
<ul class="navbar-nav flex-row">
<li class="nav-item me-3 me-lg-1">
<a class="nav-link" routerLink="/" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"><i class="fa fa-user"
aria-hidden="true"></i>
Logout <i class="fa fa-sign-out" aria-hidden="true"></i></a>
</li>
</ul>
</div>
</nav>
</div>
<div class="container containerWithNavbar">
<div class="page-inner">
<div class="d-flex text-center align-items-center align-items-md-center flex-column pt-2 pb-4 mt-5">
<div>
<img class="companyLogoTitle" src="logo/alfagreen_logo.png" alt="Logo">
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="card card-round card-black">
<div class="card-header">
<div class="card-head-row">
<div class="card-title">
Sedi impianti industriali
</div>
<div class="card-tools">
</div>
</div>
</div>
<div>
<!-- plant location map-->
<div class="mapSection">
<div class="item mx-4"
[ngStyle]="{'display': geoPoints.length == 0 ? 'block': 'none'}">
<app-loader [linesCount]="1" [lineHeight]="'600px'"></app-loader>
</div>
<div [ngStyle]="{'display': geoPoints.length ? 'block': 'none'}">
<app-geo-map-chart [geoPoints]="geoPoints"
(geoChartReady)="geoChartReady($event)" />
</div>
</div>
</div>
</div>
</div>
<div class="col-md-8">
<div class="item" [ngStyle]="{'display': plantData.length ? 'none': 'block'}">
<app-container-template>
<div cardBody>
<app-loader [linesCount]="1"></app-loader>
<app-loader [linesCount]="1" [lineHeight]="'100px'"></app-loader>
</div>
</app-container-template>
</div>
@for(plant of plantData; track plant.dataSource){
@if(plant != null && plant.payload != null && plant.payload.plant != null){
<div class="card card-round card-black">
<div class="card-header">
<div class="card-head-row">
<div class="card-title customHoverEffect" ngbTooltip="Dettagli" placement="top">
<a (click)="navigateToCompanyPlant(plant.dataSource, plant.payload.plant.id)"><i
class="fa fa-link"></i><span
class="ms-2">{{plant.payload.plant.name}}</span></a>
</div>
<div class="card-tools">
<button (click)="modalOnClick(plant.dataSource, plant.payload.plant.lookupClusters.id, plant.payload.plant.lookupClusters.name)" class="btn btn-warning" data-bs-toggle="modal"
data-bs-target="#alarmsModal">
<span class="btn-label">
<i class="fa fa-exclamation-circle"></i>
</span>
Allarmi {{plant.payload.alarmCount}}
</button>
</div>
</div>
<div class="card-body pb-0">
<div class="row">
<div class="col-sm-6 col-lg-6 mb-2">
<app-numeric-summary [description]="'Energia Giornaliera'"
[value]="plant.payload.numericSummary.dailyEnergy" [unit]="'Wh'"
[multiplicativeFactor]="1000" [showIcon]="true"
[icon]="'fa fa-solid fa-bolt'" [iconType]="'bg-success'" />
</div>
<div class="col-sm-6 col-lg-6 mb-2">
<app-numeric-summary [description]="'Potenza Attiva'"
[value]="plant.payload.numericSummary.activePower" [unit]="'W'"
[showIcon]="true" [icon]="'fa fa-plug'" [iconType]="'bg-success'"
[multiplicativeFactor]="1000" />
</div>
<!--<div class="col-sm-6 col-lg-4">
<app-numeric-summary [description]="'Potenza Reattiva'"
[value]="plant.payload.numericSummary.reactivePower" [unit]="'VAr'"
[showIcon]="true" [icon]="'fa fa-plug'" [iconType]="'bg-danger'"
[multiplicativeFactor]="1000" />
</div>-->
</div>
</div>
</div>
</div>
} @else{
<app-container-template
[headerTitle]="'Datasource: ' + plant.dataSource + ' - ' + plant.message "
[showHeader]="true">
<div cardBody>
<app-loader [linesCount]="1" [animationType]="animationType"></app-loader>
</div>
</app-container-template>
}
}
<app-container-template [headerTitle]="'Trend Potenza Giornaliera'">
<div cardBody>
<div [ngStyle]="{'height': dailyPowerData.length ? '100%': '0px'}">
<app-line-chart [legendData]="legendData" [data]="dailyPowerData"
[chartHeight]="400" [unit]="'W'" [multiplicativeFactor]="1000"
(chartReady)="onChartReady($event)" />
</div>
<div class="item" [ngStyle]="{'display': dailyPowerData.length ? 'none': 'block'}">
<app-loader [linesCount]="1" [lineHeight]="'400px'"></app-loader>
</div>
</div>
</app-container-template>
</div>
</div>
</div>
</div>
</div>
</div>
How can I resolve this?
The high level solution is to use a unique viewChild
instead of using .querySelector
to find the element by ID, because when you use multiple instances of the component in your application, the ID field will be duplicated multiple times.
So when we use viewChild
the reference to chart
always looks at the element inside the component, so there is no multiple instances, since only a single one is visible inside the component.
export class SomeComponent {
@ViewChild('chart') chart: ElementRef<any>;
// chart = viewChild('chart'); // signals approach
...
...
ngAfterViewInit() {
const container = this.chart.nativeElement;
// const container = this.chart().nativeElement; // signals approach
if (!container) {
...
}