htmlangulartypescriptdisposeecharts

echarts Apache problem dispose - console warning


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

warning console message

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} &nbsp; <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?


Solution

  • 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.

    HTML:

    TS:

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