INDEX

Mapbox, Deck.gl, and Angular for Realtime Data Visualization

#Angular#Mapbox#Deck.gl#Data Science#Blog

By Caleb Ukle at

Link to fireship.io tutorial

Datapoints Updating

Disclaimer: I've spent around 5 hours exploring deck.gl for a singular purpose of trying to get data points to render from a stream of new data. so I might not hit all your questions/needs. Also, I don't claim to be an expert on deck.gl, Mapbox, Angular, data science, etc.

First, let's understand the goal we are trying to achieve in this blog post.

  1. Get an angular app running with a Mapbox map
  2. Add deck.gl library on top of our map
  3. Add some realtime data visualization
  4. Have fun learning :)

Okay, let's go over some prerequisites for this tutorial

  • Node.js and Angular CLI installed in your working environment
  • A Mapbox access token
    • You can sign up for a mapbox account (for free) here

➡️ ️Working Example Demo

Awesome, now let's get started.

Setup

Run ng new mapbox-deck-viz --style=scss --routing in the terminal to generate a new angular app. Now let's install some dependencies, we are only working with a scatter plot today, but you want other layer types make sure to import that library if needed. i.e. aggregation-layers for Hexagon Layers

npm install @deck.gl/{core,layers,mapbox} mapbox-gl @types/mapbox-gl

Note: there are no type definitions for deck.gl which is what made this project hard, along with most examples being for the react library instead of plain js.

Let's add the Mapbox access token to our environment.ts

export const environment = {
  production: false,
  mapbox: {
    accessToken: "", // Your access token goes here
  },
};

Now to tell Mapbox what our access token is. In app.module.ts add this code

import { HttpClientModule } from "@angular/common/http";
import { environment } from "src/environments/environment";
import * as mapbox from "mapbox-gl";
(mapbox as any).accessToken = environment.mapbox.accessToken;

Now let's head to app.component.html to set up our map markup

<div #mapEl class="app-main"></div>

Generate Map Service

Before we start setting up our map, we need to generate a service that will maintain our map instance, so we can use it throughout our app, and not run into any issues where the map isn't loaded before we start trying to use it.

Generate a map service ng g s services/map/map

In the Map service, we just generated, add the following code in the MapService class and add any missing imports

map = new AsyncSubject<Map>();
constructor(
  private http: HttpClient
) {
}

getData(file = 1): Observable<any> {
  return this.http.get<any>(`../../../assets/data.${file}.json`);
}

Back to our app.component.ts. Add the following code in AppComponent and add any missing imports

Hint: Map and Navigation Control are from 'mapbox-gl' node module

@ViewChild('mapEl', {static: true})
mapEl: ElementRef<HTMLDivElement>;

private map: Map;

constructor(
  private mapSrv: MapService
) {
}

ngAfterViewInit(): void {
  this.map = new Map({
    container: this.mapEl.nativeElement,
    style: 'mapbox://styles/mapbox/dark-v9',
     center: {lng: -102.380979, lat: 35.877742},
     zoom: 4,
     pitch: 20,
    attributionControl: false
  });
  this.map.addControl(
    new NavigationControl({
      showZoom: true,
      showCompass: true,
      visualizePitch: true,
    }),
    'top-right'
  );
  this.mapSrv.map.next(this.map);

  this.map.on('load', () => {
    console.log('map loaded');
    this.mapSrv.map.complete();
  });
}

What this code is doing is initializing the map with the United States basically centered in the middle of the screen and adds basic navigation controls in the top right once the app is loaded via the mapbox-gl library

Now we need to add some styles so our map is full screen. In app.component.scss add the following code

.app-main {
  height: 100vh;
  width: 100vw;
}

remove the default margin and padding on the browser

/* styles.scss */
html,body { 
  margin: 0; 
  padding: 0;
}

run ng serve -o in the projects root directory via the terminal and you should get a page that looks like this.

Hint: don't forget to import HttpClientModule in your app.module.ts file

✅ Mapbox map in an Angular app. 🎉

Add Deck.gl

Onto step 2, let's get some data to visualize.

We are going to use the same data found in Jeff's video, but we are going to for realtime data, we are going to split the data into two files. You can try moving half the data or just copy the first couple thousand lines from the source file. We just need to see the data change. Save the two different data sets as data.1.json and data.2.json in the assets folder, or you can download them here

Now let's make a scatter plot on our map. Head to your app.component.ts file and add the following code.

setLayers(m: Map, data: any): Observable<Map> {
    const scatter = new MapboxLayer({
        id: "scatter",
        type: ScatterplotLayer,
        data,
        source: "scatter",
        opacity: 0.8,
        filled: true,
        radiusMinPixels: 2,
        radiusMaxPixels: 5,
        getPosition: d => [d.longitude, d.latitude],
        getFillColor: d =>
        d.n_killed > 0 ? [200, 0, 40, 150] : [255, 140, 0, 100],
        pickable: true,
        onHover: ({ object, x, y }) => {
            if (!!object) {
                console.log(object, x, y);
            }
        }
    });

    m.addLayer(scatter);
    return of(m);
}

ngOnDestory() {
    this.mapSrv.map.subscribe(glMap => {
        glMap.removeLayer("scatter");
    });
}

The setLayers function creates a scatter plot layer with the passed in data.

What is important here is the new MapboxLayer constructor. This is from the @deck.gl/mapbox node module. This class implements the custom layer interface for Mapbox to use on it's map. Because there isn't any typing information on these libraries, I wasn't sure what properties were supposed to go on the passed in object. The answer here is the constructor takes an object with a unique id, a type that is the name of deck.gl layer type, such as ScatterplotLayer, the rest of the properties are what is required by the deck.gl layer type found in the API documentation. Once you know that information you can create any deck.gl layer for Mapbox to consume. Nice!

Now all we have do call this method in the bottom of the ngAfterViewInit lifecycle hook with our data like so

this.mapSrv.getData(1)
  .pipe(
    switchMap((d) => combineLatest(of(d), this.mapSrv.map)),
    map(([d, glMap]) => {
      return this.setLayers(glMap, d);
    }),
  )
  .subscribe();

This will now load a scatter plot layer on top of your Mapbox map.

✅ Mapbox map with deck.gl data visualization layer

Wire up Realtime Data

Now for the last part, let's add some real-time likeness to the app.

While this app doesn't use a realtime source of data. It is wired up to where data is emitted from a single source so changing a couple lines makes it easy to pipe in any data source of your choosing.

According to the deck.gl documentation you should create a new layer every time there is a data update and deck.gl internally makes a difference in the data and only update what is needed efficiently. The only issue is I've only seen that when using new ScatterLayer since we are directly adding to Mapbox, i.e.creating a Mapbox layer then adding that layer to the map. I'm not sure if this is technically valid to regenerate a layer each time the data changes, but is the only way I was able to get things working and don't see any documentation on this specific area of the library. This is an area I'm still experimenting with. So if you're needing mission critical performance, I can't guarantee anything.

But without further adieu, let's add some dynamic data to our map.

Let's go to our app.component.html and add a button that will change our data, for now, we are going to manually call update, but this can be easily wired up with other eventing sources since we are using a Behavior Subject to control updates.

Add the following code

<button class="updates" (click)="loadData()">Send Update</button>

And add this to our app.component.scss file as well.

button.updates {
    position: fixed;
    right: 100px;
    top: 100px;
}

and in app.component.ts add the loadData() function which will load our other data json file like so.

loadData() {
  this.mapSrv.getData(2)
    .subscribe();
}

Great! now when we click our button we'll fetch our new json file, But we need to render the new data when it changes. So in our map.service.ts we'll need to add a Behavior Subject to keep track of our state. Add the following code

mapDataSub = new BehaviorSubject<any>(null);
mapData$ = this.mapDataSub.asObservable();

now back to our app.component.ts file. We'll need to change how we are fetching our data initially. Modify the code in the ngAfterViewInit lifecycle method to look like so

ngAfterViewInit(): void {
    this.mapSrv.mapData$
        .pipe(
            switchMap(d => combineLatest(of(d), this.mapSrv.map)),
            map(([data, glMap]) => {
                return this.setLayers(glMap, data)
            })
        )
        .subscribe()
    
    this.mapSrv.getData(1)
        .subscribe(d => this.mapSrv.mapDataSub.next(d))
    
    this.map = new Map({
        container: this.mapEl.nativeElement,
        style: 'mapbox://styles/mapbox/dark-v9',
        center: { lng: -102.380979, lat: 35.877742 },
        zoom: 4,
        pitch: 20,
        attributionControl: false
    });
    
    this.map.addControl(
        new NavigationControl({
            showZoom: true,
            showCompass: true,
            visualizePitch: true,
        }),
        'top-right'
    );
    
    this.mapSrv.map.next(this.map);
    
    this.map.on('load', () => {
        console.log('map loaded');
        this.mapSrv.map.complete();
    });
}

What we are doing here is setting up a subscription to our data stream and calling the first file to be sent into the stream. If you run the example right now nothing will happen because we need to emit the new data from the second data file.

In loadData, change the code to match like so

this.mapSrv.getData(2)
  .subscribe((d) => this.mapSrv.mapDataSub.next(d));

Almost there we still need to make one change in out setLayers method. At the beginning of the method add the following code.

const layer = m.getLayer("scatter");
if (!!layer) {
  m.removeLayer("scatter");
}
// Rest of function

Now our map will delete the layer if it exists and readd the layer with the new data.

When you click the update button you should see the data change. Yay! 🥳

✅ Realtime Data

Done

We have a

  • ✅ Mapbox map in Angular
  • ✅ Deck.gl layer on top of our Mapbox Map
  • Real time data visualization

Realtime for Real

While this app we've written isn't real time, I hope you can see how easily it can change into real time. All you need to do is call .next on the mapDataSub Behavior Subject and the subscription we've written in ngAfterViewInit will fire calling the setLayers method rebuilding our visualization layer. Of course these methods can be abstracted out a bit more and cleaned up, but you're smart and I believe in you!

Let me know if you come up with any ways to handle the rebuilding of layers. Have a good day! 😃