0

Well, I have two components in Angular, inicio and grafica, inicio catches data from a NGRX store and brings data from an MQTT service that is subscribed to more than 1000 topics.

The data is sent in packets of 50-100 messages per second.

The model of every message has this model:

export interface IMqttTopic {
  $idClient: string;
  $topic: string;
  $timestamp: string;
  rawValue: number | string | boolean | null;
  timestamp: number;
}

Then all the data is mapped into the following model:

export type TimeMaquina = {
  [timeKey: string]: Data;
};

export interface Data {
  time: Date;
  [key: string]: number | Date;
}

Mapped in the following way:

// global scope
rawData$ = new Observable<Data>();

// ngOnInit in InicioComponent

   this.rawData$ = this.store.select(ConsumoCabezalesDataAccessSelectors.variablesMqtt).pipe(
      filterNullish(),
      map((data) => {
        // group by time and group values into id
        const arr = data.map((ff) => {
          const sp = ff.$topic.split('/');
          const id = sp[4];
          const time = new Date(ff.timestamp);
          const timeKey = time.toLocaleString();

          return {
            [timeKey]: {
              time,
              [id]: +ff.rawValue!
            }
          } as TimeMaquina;
        });

        // https://stackdiary.com/merge-objects-in-javascript/
        return Object.assign({}, ...arr.map((f) => Object.values(f)[0])) as Data;
      })

Then this is passed to grafica component:

<app-grafica [data]="rawData$"></app-grafica>

Then... The nightmare comes...

The following code is an adapted version of this: https://gist.github.com/pjsier/fbf9317b31f070fd540c5523fef167ac

@Component({
  selector: 'app-grafica',
  templateUrl: './grafica.component.html',
  styleUrls: ['./grafica.component.scss']
})
export class GraficaComponent implements OnInit {
  ngOnInit(): void {
    const keys = Object.keys(IMaquinaKeys).filter((k) => IMaquinaKeys[k]);

    for (const key of keys) {
      Object.assign(this.localData, {
        [key]: {
          label: key,
          values: []
        }
      } as MappedData);
    }

    console.log(this.localData);

    this.data.subscribe((rawData) => {
      const time = rawData['time']?.getTime();

      if (time === undefined) {
        return;
      }

      for (const key of Object.keys(rawData)) {
        const value = rawData[key];
        if (typeof value === 'number') {
          this.localData[key].values.push({
            time,
            value
          });

          if (this.localData[key].values.length > this.maxItems) {
            this.localData[key].values.shift();
          }
        }

        if (!this.keys) {
          this.keys = Object.keys(this.localData);
        }

        select('#chart')
          .datum(this.localData)
          .call((s) => this.chart(this, this.localData, s));
      }
    });
  }

  private localData: MappedData = {};
  private keys: string[] = [];

  @Input() title = '';

  @Input() margin = { top: 0, right: 20, bottom: 20, left: 50 };
  @Input() width = 600;
  @Input() height = 400;
  @Input() duration = 500;
  @Input() color = schemeCategory10;

  @Input() yMin: number | undefined;
  @Input() yMax: number | undefined;

  @Input() yAxisFormatting?: (domainValue: NumberValue, index: number) => string;

  @Input() data = new Observable<Data>();

  @Input() maxItems = 100;

  chart(
    $this: GraficaComponent,
    data: MappedData,
    selection: d3.Selection<BaseType, MappedData, HTMLElement, unknown>
  ) {
    const t = transition().duration($this.duration).ease(easeLinear);
    const x: ScaleTime<number, number, never> = scaleTime().rangeRound([
      0,
      $this.width - $this.margin.left - $this.margin.right
    ]);
    const y: ScaleLinear<number, number, never> = scaleLinear().rangeRound([
      $this.height - $this.margin.top - $this.margin.bottom,
      0
    ]);
    const z = scaleOrdinal($this.color);

    const xMin = new Date(
      min(this.keys, function (key: string) {
        return min(data[key].values, function (d: IChartValue) {
          return d.time;
        });
      }) || Date.now()
    );
    const xMax = new Date(
      new Date(
        max(this.keys, function (key: string) {
          return max(data[key].values, function (d: IChartValue) {
            return d.time;
          });
        }) || Date.now()
      ).getTime() -
        $this.duration * 2
    );

    x.domain([xMin || Date.now() - 60000, xMax || Date.now()]);

    const yDomain = [
      $this.yMin ||
        min(this.keys, function (key: string) {
          return min(data[key].values, function (d: IChartValue) {
            return d.value;
          });
        }) ||
        0,
      $this.yMax ||
        max(this.keys, function (key: string) {
          return max(data[key].values, function (d: IChartValue) {
            return d.value;
          });
        }) ||
        1
    ];
    y.domain(yDomain);
    z.domain(
      this.keys.map(function (key: string) {
        return data[key].label;
      })
    );

    const svg = selection.selectAll('svg').data([data]);
    const gEnter = svg
      .join(
        (enter) => enter.append('svg').append('g'),
        (update) => update,
        (exit) => exit.remove()
      );
    gEnter.append('g').attr('class', 'axis x');
    gEnter.append('g').attr('class', 'axis y');

    gEnter.append('g').attr('class', 'axis-grid x');
    gEnter.append('g').attr('class', 'axis-grid y');

    gEnter
      .append('defs')
      .append('clipPath')
      .attr('id', 'clip')
      .append('rect')
      .attr('width', $this.width - $this.margin.left - $this.margin.right)
      .attr('height', $this.height - $this.margin.top - $this.margin.bottom);
    gEnter
      .append('g')
      .attr('class', 'lines')
      .attr('clip-path', 'url(#clip)')
      .selectAll('.data')
      .data(this.keys)
      .join(
        (enter) => enter.append('path').attr('class', 'data'),
        (update) => update,
        (exit) => exit.remove()
      );

    const legendEnter = gEnter
      .append('g')
      .attr('class', 'legend')
      .attr('transform', 'translate(' + ($this.width - $this.margin.right - $this.margin.left - 75) + ',25)');
    legendEnter.append('rect').attr('width', 50).attr('height', 75).attr('fill', '#ffffff').attr('fill-opacity', 0.7);
    legendEnter
      .selectAll('text')
      .data(this.keys)
      // .enter()
      .join(
        (enter) =>
          enter
            .append('text')
            .attr('y', function (d, i) {
              return i * 20 + 25;
            })
            .attr('x', 5)
            .attr('fill', (key: string) => {
              return z(data[key].label);
            }),
        (update) => update,
        (exit) => exit.remove()
      );

    svg.attr('width', $this.width).attr('height', $this.height);

    const g = svg.select('g').attr('transform', 'translate(' + $this.margin.left + ',' + $this.margin.top + ')');

    const INNER_WIDTH = $this.width - $this.margin.left - $this.margin.right;
    const INNER_HEIGHT = $this.height - $this.margin.top - $this.margin.right;

    const xAxisGrid = axisBottom(x)
      .tickSize(-INNER_HEIGHT)
      .tickFormat(() => '')
      .ticks(10);
    const yAxisGrid = axisLeft(y)
      .tickSize(-INNER_WIDTH)
      .tickFormat(() => '')
      .ticks(10);

    g.select('g.axis.x')
      .attr('transform', 'translate(0,' + ($this.height - $this.margin.bottom - $this.margin.top) + ')')
      .transition(t)
      .call(
        axisBottom(x)
          .ticks(10)
          .tickFormat((d) => (d as Date).toLocaleTimeString()) as any
      );

    g.select('g.axis.y')
      .transition(t)
      .attr('class', 'axis y')
      .call(
        axisLeft(y)
          .ticks(10)
          .tickFormat($this.yAxisFormatting || $this.yAxisFormat) as any
      );

    g.select('g.axis-grid.x')
      .transition(t)
      .attr('transform', 'translate(0,' + INNER_HEIGHT + ')')
      .call(xAxisGrid as any);

    g.select('g.axis-grid.y')
      .transition(t)
      .call(yAxisGrid as any);

    g.select('defs clipPath rect').transition(t).attr('width', INNER_WIDTH).attr('height', INNER_HEIGHT);

    g.selectAll('g path.data')
      .data(this.keys)
      .style('stroke', function (key: string) {
        return z(data[key].label);
      })
      .style('stroke-width', 1)
      .style('fill', 'none')
      .transition()
      .duration($this.duration)
      .ease(easeLinear)
      .on('start', function () {
        tick(this);
      });

    g.selectAll('g .legend text')
      .data(this.keys)
      .text(function (key: string) {
        return data[key].label.toUpperCase() + ': ' + data[key].values[data[key].values.length - 1]?.value + ' A';
      });

    // For transitions https://bl.ocks.org/mbostock/1642874
    function tick($$this: BaseType) {
      const lineFunc = line()
        .curve(curveBasis)
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        .x(function (d: any) {
          return x(d.time);
        })
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        .y(function (d: any) {
          return y(d.value);
        });

      gEnter
        .data($this.keys)
        .join(
          (enter) => enter.append('path').attr('class', 'data'),
          (update) => update,
          (exit) => exit.remove()
        );

      g.selectAll('g path.data')
        .data($this.keys)
        .attr('d', function (key: string) {
          const l = lineFunc(
            data[key].values.filter((v: any) => v.value !== undefined && !Number.isNaN(v.time)) as any //.map((dd) => [dd.time, dd.value])
          );
          return l;
        })
        .attr('transform', null);

      const xMinLess = new Date(new Date(xMin || Date.now()).getTime() - $this.duration);
      const translate = 'translate(' + x(xMinLess) + ', 0)';
      active($$this)
        ?.attr('transform', translate)
        .transition()
        .on('start', function () {
          tick($$this);
        });
    }
  }

  private yAxisFormat(d: NumberValue): string {
    return `${d} A`;
  }
}

As you can see, I'm storing all the data on a localData which I'm writing it only on ngOnInit and reading this on multiple places...

I'll try to make an isolated version of this in order to make your test easier.

I'm trying to debug using Chrome dev tools memory profiling in order to detect where the memory leak is without luck.

Also, as you can see the original one has the enter() function which is leaking data, and not removing it.

That's why I'm using the join() function:

.join(
        (enter) => enter.append('svg').append('g'),
        (update) => update,
        (exit) => exit.remove()
      );

Without any luck neither... Maybe I'm using it wrongly.

Also, I'm suspecting that I'm doing too many mappings and maybe I'm not disposing/destroying its references...

That's why I do:

         if (this.localData[key].values.length > this.maxItems) {
            this.localData[key].values.shift();
          }

My grafica HTML is as simple as this:

<div id="chart"></div>

z3nth10n
  • 2,341
  • 2
  • 25
  • 49

0 Answers0