post-photo

Drawing pie charts on Google Maps - chasing the performance

Disclaimer

This post, unlike most of ours, will focus more on the problem, rather than on the solution. Nevertheless, you will get a solution, but it is pretty far from a nice one, so I would like to take this opportunity to “call out” any developer, who has a better solution with the same technological stack.

Backstory

As a junior developer, I often get small tasks in the calm between the storms of big projects. This was the case, when I inherited an old(er) project of ours, written in Angular 2, where a weird performance issue occured. The main concept was simple:

So at every change you get a new batch of data. The problem was, if you used the app for more than one iteration, it got extremely slow and unresponsive, looking like a memory leak, because Chrome was using more and more memory (I mean more than it usally leaks…).

You would think this has some Google-supported, standardized solution. Well, you are right and wrong and the same time, and so was I. It is rather standardized, than solution. :)

What is Google Visualization?

Google Visualization is an API, which exposes objects and methods under the namespace of “google.visualization.*”. Integration into an Angular project is a bit tricky, because it’s not an installable package. Currently it looks like this:

<script type="text/javascript" src="https://www.google.com/jsapi"></script>
<script type="text/javascript">
   google.load('visualization', '1.0', {
     'packages': ['corechart']
   });
 </script>

There is a new version of the loader script, looking like this but it does not solve any of our issues:

<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
<script type="text/javascript">
  google.charts.load('current', {packages: ['corechart']});
</script>

If you don’t want to bookworm your way through the whole library, you should add this interface az “types” to your project. These are actual npm packages helping you to use your customly loaded script. The related part of package.json.

"@types/google.visualization": "0.0.43",
"@types/googlemaps": "^3.30.13"

If you add types as npm packages, you should also add them in your tsconfig.json.

"types": [
    "googlemaps",
    "google.visualization"
],

And in the component where you want to use them they are imported in a pretty weird fashion (and these always should be at the start of the file).

/// <reference types="googlemaps"/>
/// <reference types="google.visualization"/>

These things may not be new to most of you, but it took me serious Google time how do these things work and this was the only way the Ahead-Of-Time build did not fail (on the types).

Old solution

The state I got the codebase looked like the official example from the Google Visualization Docs, so I stood perplexed. This should work. My favourite stage of debugging. Let’s do a little bit of autopsy.

  1. I need a map.
ngAfterViewInit() {
    this.map = new google.maps.Map(document.getElementById('map'), {
      zoom: 12,
      center: constants.defaultMapCenter,
      mapTypeId: google.maps.MapTypeId.TERRAIN,
      streetViewControl: false,
      zoomControlOptions: {
        position: google.maps.ControlPosition.TOP_LEFT
      }
    });
}

So, with this, and a <div id=“map”> in the HTML, we have our map.

  1. We want to draw on the map. This is where it gets tricky. The Google Maps is composed by square images displaying a grid without gaps, so you see it as a coherent map. If you zoom or move on the map, new images get downloaded, replacing the old ones, so you can’t draw on those. We need overlays. These overlays can be positioned on the map with lat-lon coordinate pairs, so it’s perfect, we have the canvas on the right position.

  2. Now this is where “things” get a bit vanilla, we draw one piechart on one overlay. This is how it’s handled in the component (this is actually the “pretty” part):

if (this.pieCharts.length > 0) {
    const bounds = new google.maps.LatLngBounds();
 
    let chartData;
 
    this.pieCharts.forEach((pieChart: PieChartData) => {
        this.chartOptions.colors = pieChart.colors;
 
        const position = new google.maps.LatLng(pieChart.location.latitude, pieChart.location.longitude);
        chartData = google.visualization.arrayToDataTable(pieChart.chartdata, true);
 
        bounds.extend(position);
 
        this.overlays.push(new USGSOverlay(position, chartData, this.chartOptions, this.map, pieChart.size));
    });
 
    this.map.fitBounds(bounds);
}

And this is what happens in our custom overlay class.

import PieChart = google.visualization.PieChart;
 
export class USGSOverlay extends google.maps.OverlayView {
  latLng_;
  chartData_;
  chartOptions_;
  map_;
  modifier_;
 
  div_;
  inner_;
 
  constructor(latLng, chartData, chartOptions, map, modifier) {
    super();
    // Initialize all properties.
    this.latLng_ = latLng;
    this.chartData_ = chartData;
    this.chartOptions_ = chartOptions;
    this.map_ = map;
    this.modifier_ = modifier;
 
    // Define a property to hold the image's div. We'll
    // actually create this div upon receipt of the onAdd()
    // method so we'll leave it null for now.
    this.div_ = null;
    this.inner_ = null;
 
    // Explicitly call setMap on this overlay.
    this.setMap(map);
  }
 
  /**
   * onAdd is called when the map's panes are ready and the overlay has been
   * added to the map.
   */
  onAdd() {
 
    const width = 100;
    const height = 100;
    const inner = document.createElement('div');
    inner.style.position = 'relative';
 
    inner.style.left = '-50%';
    inner.style.top = '-50%';
    inner.style.width = width * (this.modifier_ / 100) + 'px';
    inner.style.height = height * (this.modifier_ / 100) + 'px';
    inner.style.fontSize = '1px';
    inner.style.lineHeight = '1px';
    inner.style.border = '0px none';
    inner.style.padding = '0px';
    inner.style.backgroundColor = 'transparent';
    inner.style.cursor = 'default';
    this.inner_ = inner;
 
    const div = document.createElement('div');
    div.style.position = 'absolute';
    div.style.display = 'none';
    div.appendChild(inner);
 
    this.div_ = div;
 
    // Add the element to the 'overlayLayer' pane.
    const panes = this.getPanes();
    panes.overlayMouseTarget.appendChild(div);
  };
 
  draw() {
 
    const projection = this.getProjection();
    const position = projection.fromLatLngToDivPixel(this.latLng_);
 
    this.div_.style.left = position.x + 'px';
    this.div_.style.top = (position.y - 50) + 'px';
    this.div_.style.display = 'block';
    const chart = new PieChart(this.inner_);
    chart.draw(this.chartData_, this.chartOptions_);
 
  };
 
  // The onRemove() method will be called automatically from the API if
  // we ever set the overlay's map property to 'null'.
  onRemove() {
    this.div_.parentNode.removeChild(this.div_);
    this.div_ = null;
  };
};

This is an actually working solution, relying heavily on Google Visualization libraries. It looks pretty textbook. It’s easy. EASY.

text

The problem in details and research

Let’s see a simple example. I have an empty form with empty maps (so no charts). In this case, the JavaScript VM uses 44.1 MB/51.4 MB (used/allocated). If I load 9 piecharts on the map (which is a pretty common number of piecharts while using this app), it’s like 4 MB more. But if I scroll out, it’s 67/101, scrolling back 75/101. This is actually not a big leak, been worse, will be worse, so let’s investigate.

First I thought to solve the problem without the slightest effort: it must be some under-the-hood performance issue in Angular, so just update the framework and see if the issue is solved. So I updated the project from Angular 2.4.10 to Angular 6.1.7, including every dependency and refactoring everything that broke, so close to every component and service. It is just as fun as it sounds like, unfortunately it had nothing to do with the issue.

Then came the Google Driven Development. I found an incredible lot of performance issues connecting to Google Maps integrated into web applications. This was the first thing that derailed my train of thought: must be the maps fault. Downlading images, some of them must be stuck in the memory. I made heap snapshots and allocation timeline graphs (this sounds serious, but these are built in features in Chrome Developer Console, Memory page) and I concluded two things:

  1. I don’t understand 90% of the data on the screen.
  2. The allocation is in fact sourced by Google code, so it can be either the map, the overlay, or the chart. So back to square one.

The next step was trying to free the memory with “hacky” code. I found some sniplets freeing the space allocated by the actual map and by listeners on the map, but it decreased the allocated space only by a tiny bit. Actually, this was the point when I got the kickstart to understand what’s going on, because it got to me, that I read something about listeners on the console before.

text

So…these warnings…actually WARN?

text

This happens, if I load the 9 charts mentioned above. What if I scroll?

text

THIS WAS ONLY THREE MOUSE SCROLL TICKS! So what if I scroll back?

text

The two “mousemove” handler you see was to put my cursor over the console to use snipping tool. PROCESSING THIS ALMOST TOOK HALF A SECOND! Hmm. This is not memory leak. This is the active listeners blocking precious CPU time from the most basic processes of the browser. Just for fun, let’s ask what does the OS say about this.

This is the app running in development, in incognito as only open tab, with the 373 non-passive event listener, without my cursor over.

text

And this is if I move my cursor over the map.

text

It takes 16.3% of the CPU. My CPU is an Intel Core i7-4790. Ok, it’s not the latest, but it’s still an i7. And this is JUST Google Maps with pie charts.

text

New solution

The bug being discovered, I had to fix it the way it

First, I had to choose what to replace. If I didn’t draw the charts, the performance was fine, so the chart drawing dependency had to go. I was about to drop the whole layout class too, but I realized, I had no other way to place the charts to the coordinates as neatly as before. I used ngx-charts instead of the Google solution, which proved perfectly enough for the task.

Second, I had to do quite the refactor. This is the part where it gets “vanilla-hacky-ohgodwhy”, so I hope some of the readers can come up with a prettier solution.

This is what it looks like in HTML, no big deal, but there is an interesting part in the “pie-chart” CSS class: it uses display: none. At first, I draw my charts invisible, but in the DOM.

<div class="wrapper">
    <ngx-charts-pie-chart class="pie-chart" *ngFor="let piechart of pieCharts; let i = index;" [results]="piechart.chartdata" [view]="piechart.sizeToDraw"
      [scheme]="colors[i]">
    </ngx-charts-pie-chart>
    <div id="map">
    </div>
</div>

I use a gutted version of our previous overlay class, it only does one thing: puts the canvas to the right place on the map, with an ID set, so I know how to pair them. So I have my piecharts drawn, but not there, and I have my canvases. Node.appendChild() to the rescue! It does one simple thing: grabs a HTML node and puts it somewhere else, as a child of an other element. So this is how I put my charts on the canvas, pairing by ID:

const chartElements = [].slice.call(document.getElementsByTagName('ngx-charts-pie-chart'));
this.overlays.forEach((overlay, index) => {
    const chartToInsert = chartElements.shift();
    if (chartToInsert && document.getElementById(index.toString())) {
        chartToInsert.style.position = 'relative';
        chartToInsert.style.left = '-50%';
        chartToInsert.style.top = '-75px';
        chartToInsert.style.display = 'block';
        document.getElementById(index.toString()).appendChild(chartToInsert);
    }
});

After this, no more lagging and irresponsivity from the app, works as intended.

text

Summary

This task with the research and fixing gave a pretty epic goosechase-experience to me with insight into Angular update process and Google Visualization libraries. Despite succeeding, it would mean a lot if someone could come up with a “more 2019” approach in the comments or sent to my Wanari e-mail address.

/* Because the post is themed around piecharts, I only used PewDiePie memes :) */

member photo

Müzli has been with #TeamWanari for a year! He loves implementing beautiful designs.

Latest post by Krisztián Martinkovics

Mobile Apps in a Blink