RxJS – Unlock App Potential: Angular, Google Maps and Firebase

In this series of posts, we have explored the architecture and lower-level details of the protests map application. Moving forward, we will be utilizing Firebase services and Google Maps in combination with Angular’s official APIs, and implementing state management with RxJS.

Table of Contents

Prerequirements setup

Before we start coding we want to make sure we have the following:

  • Firebase account https://firebase.google.com/
  • Google Maps API key https://developers.google.com/maps/documentation/javascript/get-api-key
  • NodeJS 12 or later versionhttps://nodejs.org/en/download/
  • Firebase CLInpm install -g firebase-tools / yarn global add firebase-tools
  • Angular 13https://angular.io/cli

Project setup

Run the following commands to set up a new project

ng new protests-map && cd protests-map

This might take a minute or so, when the new project is created, open the src/index.html file, in the head element add your Google Maps API key as follows:

<script
  defer
  src="https://maps.googleapis.com/maps/api/js?key=<your key>"
  ></script>

We will be using also the marker cluster, so let’s add it inside the head element as well:

<script src="https://cdnjs.cloudflare.com/ajax/libs/js-marker-clusterer/1.0.0/markerclusterer_compiled.js"></script>

Next, configure firebase with the project:

ng add @angular/fire

The above command takes us through Firebase set-up, first step is to allow installation and execution of the @angular/fire package. Then choose the Firebase features we want for this project which are: ng deploy, Authentication, and Firestore.

The next step will be to log in to the Firebase account. Then, If you already have a project, choose it, if not, create one.

Next, choose the default site URL where the app will be available, we can change it later.

Last, choose again the app for deployment.

Once the setup is finished, the firebase configuration is on the env files and the app module is updated with the following attributes on the imports array:

    provideFirebaseApp(() => initializeApp(environment.firebase)),
    provideAuth(() => getAuth()),
    provideFirestore(() => getFirestore()),

Remove it from the app module, instead, put it on the core module, so it stays close to our Firestore and auth abstract classes.

Create Firestore Flags Collection

On the newly created project, create a new collection named flag, as Firestore allows flexible schemas, we can leave the first record with only the auto-generated ID.

Generate Core Module

Let’s generate our core module using the Angular CLI, and generate a new module, named core:

ng g m core

The generated CoreModule class file is as follows:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';



@NgModule({
  declarations: [],
  imports: [
    CommonModule
  ]
})
export class CoreModule { }

Add the @angular/fire configuration:

import { AngularFireModule } from '@angular/fire/compat';
import { AngularFirestoreModule } from '@angular/fire/compat/firestore';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { environment } from '../../environments/environment';

@NgModule({
  declarations: [],
  imports: [
    CommonModule,
    AngularFireModule.initializeApp(environment.firebase),
    AngularFirestoreModule,
  ],
})
export class CoreModule {}

RxJS Firestore Factory Class

In the core directory, create a new file named, firestore.abstract.ts the initial file looks like this:

import { AngularFirestore, QueryFn } from '@angular/fire/compat/firestore';
import { Inject, Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export abstract class FirestoreAbstract<T> {}

The Injectable decorator marks our class as available to be provided and injected as a dependency. Let’s update our class as follows:

import { Inject, Injectable } from '@angular/core';
import { AngularFirestore, QueryFn } from '@angular/fire/compat/firestore';
import { environment } from '../../environments/environment';
import { tap } from 'rxjs/operators';

@Injectable({
  providedIn: 'root',
})
export abstract class FirestoreAbstract<T> {
  protected abstract basePath: string;
  constructor(
    @Inject(AngularFirestore) protected firestore: AngularFirestore
  ) {}

  doc$(id: string) {
    return this.firestore
      .doc<T>(`${this.basePath}/${id}`)
      .valueChanges()
      .pipe(
        tap((r: T | undefined) => {
          if (!environment.production) {
            console.groupCollapsed(
              `Firestore Streaming [${this.basePath}] [doc$] ${id}`
            );
            console.log(r);
            console.groupEnd();
          }
        })
      );
  }

  collection$(queryFn?: QueryFn) {
    return this.firestore
      .collection<T>(`${this.basePath}`, queryFn)
      .valueChanges()
      .pipe(
        tap((r: T[]) => {
          if (!environment.production) {
            console.groupCollapsed(
              `Firestore Streaming [${this.basePath}] [collection$]`
            );
            console.table(r);
            console.groupEnd();
          }
        })
      );
  }

  async update(value: T, id: string) {
    try {
      await this.collection.doc(id).set(Object.assign({}, { id }, value));
      if (!environment.production) {
        console.groupCollapsed(`Firestore Service [${this.basePath}] [update]`);
        console.log('[Id]', id, value);
        console.groupEnd();
      }
    } catch (error) {
      throw new Error(error as string);
    }
  }

  async create(value: T) {
    try {
      const id = this.firestore.createId();
      const document = Object.assign({}, { id }, value);
      await this.collection.doc(id).set(document);
      if (!environment.production) {
        console.groupCollapsed(`Firestore Service [${this.basePath}] [create]`);
        console.log('[Id]', id, value);
        console.groupEnd();
      }
      return document;
    } catch (error) {
      throw new Error(error as string);
    }
  }

  async delete(id: string) {
    try {
      await this.collection.doc(id).delete();
      if (!environment.production) {
        console.groupCollapsed(`Firestore Service [${this.basePath}] [delete]`);
        console.log('[Id]', id);
        console.groupEnd();
      }
    } catch (error) {
      throw new Error(error as string);
    }
  }

  private get collection() {
    return this.firestore.collection(`${this.basePath}`);
  }
}

The FirestoreAbstract class is a factory class, it uses a generic type <T>, this type will specify this type when implementing the abstract class on a specific service. The name of the class might be not fit the Angular naming convention, or in general, there might be a better description, but since this class is not implemented directly, I wouldn’t call it a service.

RxJS Pagestore Abstract Class

Under the core directory, create a new file named pagestore.abstract.ts, and edit it with the following code:

import { BehaviorSubject, Observable } from 'rxjs';

import { environment } from 'src/environments/environment';

export abstract class PagestoreAbstract<T> {
  protected bs!: BehaviorSubject<T>;
  state$: Observable<T>;
  state: T;
  previous?: T;
  protected abstract pagestore: string;

  constructor(initialValue: Partial<T>) {
    this.bs = new BehaviorSubject<T>(initialValue as T);
    this.state$ = this.bs.asObservable();
    this.state = initialValue as T;
    this.state$.subscribe((s) => {
      this.state = s;
    });
  }

  patch(newValue: Partial<T>, event: string = 'Not specified'): void {
    this.previous = this.state;
    const newState = Object.assign({}, this.state, newValue);
    localStorage.setItem(this.pagestore, JSON.stringify(newState));
    if (!environment.production) {
      console.groupCollapsed(
        `[${this.pagestore} store] [patch] [event: ${event}]`
      );
      console.log('change', newValue);
      console.log('prev', this.previous);
      console.log('next', newState);
      console.groupEnd();
    }
    this.bs.next(newState);
  }
}

Again we have a generic type that will be implemented on a domain level such as flag or user. In the patch method, we use local storage, this will become helpful when we will want to store the user id for the flag.

Generate Flag Module

The factory is ready, let’s produce our flag module by running:

ng g m flag

We will need to import Google Maps and Angular Form modules in our flag, so edit as so it looks like this:

import { FormsModule, ReactiveFormsModule } from '@angular/forms';

import { CommonModule } from '@angular/common';
import { GoogleMapsModule } from '@angular/google-maps';
import { NgModule } from '@angular/core';

@NgModule({
  declarations: [],
  imports: [CommonModule, FormsModule, ReactiveFormsModule, GoogleMapsModule],
})
export class FlagModule {}

You will get an error, run one of the following commands to install @angular/google-maps:

ng add @angular/google-maps

or

yarn add @angular/google-maps

or

npm install @angular/google-maps --save

Add Flag Interface

Under the flag directory create a file named flag.ts and edit it as follow:

export interface Flag {
  id?: string;
  options: {
    icon: {
      url: string;
    };
    animation: number;
  };

  location: {
    lat: number;
    lng: number;
  };
  uid?: string;
  createdAt?: number;
  lastModified?: number;
}

The options and location attributes are required for the map form, the rest might be undefined.

Create Flag Pagestore Instance

To keep our flag page store attributes separate from its implementation, on the same directory create a flag.state.ts file and edit it like this:

import { Flag } from './flag';

export interface FlagState {
  loading: boolean;
  formStatus: string;
  flags: Flag[];
  myFlags: Flag[];
  liveFlags: Flag[];
  hasLiveFlag?: boolean;
  totalFlags: number;
  totalLiveFlags: number;
  totalUserFlags: number;
  totalUniqueUsers: number;
  error?: string;
  action?: string;
}

Same as with the abstract classes, the naming is not necessarily the most conventional, in fact, in the next snippet we could generate these files from Angular CLI:

ng generate service flag/flag-pagestore

This will generate a file named: flag-pagestore.servcie.ts and if every file that is not a component or a module is a service, it can cause rather confusion than a modular app.

Create a file named flag.pagestore.ts under the flag directory and edit it as follow:

import { FlagState } from './flag.state';
import { Injectable } from '@angular/core';
import { PagestoreAbstract } from '../core/pagestore.abstract';

@Injectable({
  providedIn: 'root',
})
export class FlagPagestore extends PagestoreAbstract<FlagState> {
  protected pagestore = 'flag';
  constructor() {
    super({
      loading: true,
      formStatus: '',
      flags: [],
      myFlags: [],
      liveFlags: [],
      totalFlags: 0,
      totalLiveFlags: 0,
      totalUserFlags: 0,
    });
  }
}

The FlagPagstore extends the PagestoreAbstract with the FlagState as the generic type argument. Then we call the super method, which is the constructor of our PagestoreAbstract class, and we pass the flag initial state as the argument of the initialValue parameter.

Create Flag Firestore Instance

Add a new file inside the flag directory, named flag.firestore.ts, and edit it as follow:

import { FirestoreAbstract } from '../core/firestore.abstract';
import { Flag } from './flag';
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class FlagFirestore extends FirestoreAbstract<Flag> {
  protected basePath = 'flags';
}

The FlagFirestore class pass the Flag type as an argument of the PagestoreAbstract generic, and then it implements the inherited abstract member ‘basePath’ and sets it to our collection name.

Generate Flag Service

Our flag pagestore and firestore classes are ready to use by the FlagService class, run the following command to generate our service:

ng g s --skip-tests=true flag/flag

Running the command without the –skip-tests flag will create also the spec file, which is helpful when creating a business app, but tests are out of the scope of the protests map application.

Our FlagService should look like this:

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class FlagService {

  constructor() { }
}

We will edit our service in three steps, first, we edit our constructor and its private methods, so our FlagService should look like this:

import { Flag } from './flag';
import { FlagFirestore } from './flag.firestore';
import { FlagPagestore } from './flag.pagestore';
import { Injectable } from '@angular/core';
import { map } from 'rxjs';
@Injectable({
  providedIn: 'root',
})
export class FlagService {
  uid = '';
  constructor(
    private firestore: FlagFirestore,
    private pagestore: FlagPagestore
  ) {
    this.firestore
      .collection$()
      .pipe(
        map((flags) => {
          this.pagestore.patch(
            {
              loading: false,
              flags,
              liveFlags: this.getLiveFlags(flags),
              hasLiveFlag: this.getHasLiveFlags(flags),
              myFlags: flags.filter((flag) => flag.uid === this.uid),
              totalFlags: flags.length,
              totalLiveFlags: this.getTotalLiveFlags(flags),
              totalUserFlags: flags.filter((flag) => flag.uid === this.uid)
                .length,
              totalUniqueUsers: this.getTotalUniqueUsers(flags),
            },
            'flags subscription'
          );
        })
      )
      .subscribe();
  }

  private getLiveFlags(flags: Flag[]) {
    return flags.filter(
      (flag) => Date.now() - flag.createdAt! < 60 * 60 * 1000
    );
  }

  private getHasLiveFlags(flags: Flag[]) {
    return (
      flags.filter(
        (flag) =>
          flag.uid === this.uid && Date.now() - flag.createdAt! < 60 * 60 * 1000
      ).length > 0
    );
  }

  private getTotalLiveFlags(flags: Flag[]) {
    return flags.filter((flag) => Date.now() - flag.createdAt! < 60 * 60 * 1000)
      .length;
  }

  private getTotalUniqueUsers(flags: Flag[]) {
    const uniqueUsers = [...new Set(flags.map((flag) => flag.uid))];
    return uniqueUsers.length;
  }
}

The uid property is the user ID, since we didn’t implement our user module, we leave it as an empty string, we will come back to it.

The constructor injects our FlagFirestore and FlagPagestore, by subscribing to our firestore collection observable, we pipe the flags data to our pagestore patch method.

The next step is to add our pagestore getters methods and to instance our FlagFirestore methods. Our FlagService class should look as follow:

import { Flag } from './flag';
import { FlagFirestore } from './flag.firestore';
import { FlagPagestore } from './flag.pagestore';
import { Injectable } from '@angular/core';
import { map } from 'rxjs';
@Injectable({
  providedIn: 'root',
})
export class FlagService {
  uid = '';
  constructor(
    private firestore: FlagFirestore,
    private pagestore: FlagPagestore
  ) {
    this.firestore
      .collection$()
      .pipe(
        map((flags) => {
          this.pagestore.patch(
            {
              loading: false,
              flags,
              liveFlags: this.getLiveFlags(flags),
              hasLiveFlag: this.getHasLiveFlags(flags),
              myFlags: flags.filter((flag) => flag.uid === this.uid),
              totalFlags: flags.length,
              totalLiveFlags: this.getTotalLiveFlags(flags),
              totalUserFlags: flags.filter((flag) => flag.uid === this.uid)
                .length,
              totalUniqueUsers: this.getTotalUniqueUsers(flags),
            },
            'flags subscription'
          );
        })
      )
      .subscribe();
  }

  get flags$() {
    return this.pagestore.state$.pipe(map((state) => state.flags));
  }
  get myFlags$() {
    return this.pagestore.state$.pipe(map((state) => state.myFlags));
  }
  get liveFlags$() {
    return this.pagestore.state$.pipe(map((state) => state.liveFlags));
  }
  get hasLiveFlag$() {
    return this.pagestore.state$.pipe(map((state) => state.hasLiveFlag));
  }
  get loading$() {
    return this.pagestore.state$.pipe(map((state) => state.loading));
  }
  get error$() {
    return this.pagestore.state$.pipe(map((state) => state.error));
  }
  get action$() {
    return this.pagestore.state$.pipe(map((state) => state.action));
  }
  get formStatus$() {
    return this.pagestore.state$.pipe(map((state) => state.formStatus));
  }
  get totalFlags$() {
    return this.pagestore.state$.pipe(map((state) => state.totalFlags));
  }
  get totalLiveFlags$() {
    return this.pagestore.state$.pipe(map((state) => state.totalLiveFlags));
  }
  get totalUserFlags$() {
    return this.pagestore.state$.pipe(map((state) => state.totalUserFlags));
  }
  get totalUniqueUsers$() {
    return this.pagestore.state$.pipe(map((state) => state.totalUniqueUsers));
  }

  async create(flag: Flag) {
    if (this.pagestore.state.hasLiveFlag) {
      return await this.update(flag);
    }
    this.pagestore.patch(
      {
        loading: true,
        flags: [],
        myFlags: [],
        formStatus: 'saving',
      },
      'create flag'
    );
    try {
      await this.firestore.create(flag);
      setTimeout(() => {
        this.pagestore.patch(
          {
            loading: false,
            formStatus: 'saved',
          },
          'created flag'
        );
        window.location.href = '/flags';
      }, 1000);
    } catch (error) {
      this.pagestore.patch(
        {
          loading: false,
          formStatus: 'error',
          error: new Error(error as string).message,
          action: 'try again',
        },
        'error creating flag'
      );
    }
  }

  private async update(flag: Flag) {
    this.pagestore.patch(
      { loading: true, formStatus: 'saving' },
      'updating flag'
    );
    try {
      await this.firestore.update(flag, this.pagestore.state.myFlags[0].id!);
      setTimeout(() => {
        this.pagestore.patch(
          { loading: false, formStatus: 'saved' },
          'updated flag'
        );
        window.location.href = 'flags';
      }, 1000);
    } catch (error) {
      this.pagestore.patch(
        {
          loading: false,
          formStatus: 'error',
          error: new Error(error as string).message,
          action: 'try again',
        },
        'error updating flag'
      );
    }
  }
  private getLiveFlags(flags: Flag[]) {
    return flags.filter(
      (flag) => Date.now() - flag.createdAt! < 60 * 60 * 1000
    );
  }

  private getHasLiveFlags(flags: Flag[]) {
    return (
      flags.filter(
        (flag) =>
          flag.uid === this.uid && Date.now() - flag.createdAt! < 60 * 60 * 1000
      ).length > 0
    );
  }

  private getTotalLiveFlags(flags: Flag[]) {
    return flags.filter((flag) => Date.now() - flag.createdAt! < 60 * 60 * 1000)
      .length;
  }

  private getTotalUniqueUsers(flags: Flag[]) {
    const uniqueUsers = [...new Set(flags.map((flag) => flag.uid))];
    return uniqueUsers.length;
  }
}

The third step will be to add a method that gets the user id from local storage, we will do it later, in the next post of this series, let’s move on to our components.

Generate Flag Form Component

Generate the form component by running:

ng g c  --skip-tests=true flag/flag-form

This will create three files

  • flag-form.component.ts
  • flag-form.component.html
  • flag-form.component.scss

Add Routing for Flag Form

Before we start editing our form component, let’s add it to the app-routing.module.ts, so first we should add the FlagModule to our AppModule imports array.

Then edit the app-routing file so it looks like this:

import { RouterModule, Routes } from '@angular/router';

import { FlagFormComponent } from './flag/flag-form/flag-form.component';
import { NgModule } from '@angular/core';

const routes: Routes = [{ path: '', component: FlagFormComponent }];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {}

Next, edit the app.component.html as follows:

<html>
  <router-outlet></router-outlet>
</html>

Start the app by running

ng serve

In your browser, go to localhost:4200, you should see this:

Flag form component.

Edit Flag Form Component

First, download the flag icon from this link, add the icons to the assets directory – /src/assets/icons.

In the flag-form directory create a new file named flag-colors.ts:

const flagColors = [
  {
    color: 'pink-flag',
    title: 'Feminist',
    url: 'assets/icons/pink-flag.svg',
  },
  {
    color: 'blue-flag',
    title: 'Rightist',
    url: 'assets/icons/blue-flag.svg',
  },
  {
    color: 'red-flag',
    title: 'Leftist',
    url: 'assets/icons/red-flag.svg',
  },
  {
    color: 'yellow-flag',
    title: 'Liberal',
    url: 'assets/icons/yellow-flag.svg',
  },
  {
    color: 'green-flag',
    title: 'Greenie',
    url: 'assets/icons/green-flag.svg',
  },
  {
    color: 'gray-flag',
    title: 'Anarchist',
    url: 'assets/icons/gray-flag.svg',
  },
];

export default flagColors;

On the same directory add a new file named mapOptions.ts

const mapOptions: google.maps.MapOptions = {
  mapTypeId: 'roadmap',
  zoomControl: true,
  scrollwheel: true,
  zoom: 11,
  disableDoubleClickZoom: false,
  maxZoom: 32,
  minZoom: -4,
  disableDefaultUI: true,
  styles: [
    {
      featureType: 'poi',
      elementType: 'labels',
      stylers: [{ visibility: 'off' }],
    },
  ],
  fullscreenControl: false,
};

export default mapOptions;

Edit flag-form.component.ts:

import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';

import type { Flag } from '../flag';
import { FlagService } from '../flag.service';
import type { Observable } from 'rxjs';
import flagColors from './flag-colors';
import mapOptions from './mapOptions';

@Component({
  selector: 'app-flag-form',
  templateUrl: './flag-form.component.html',
  styleUrls: ['./flag-form.component.scss'],
})
export class FlagFormComponent implements OnInit {
  loading$?: Observable<boolean>;
  error$?: Observable<string | undefined>;
  action$?: Observable<string | undefined>;
  flags$?: Observable<Flag[]>;

  flags?: Flag[];
  flagForm = new FormGroup({
    uid: new FormControl(''),
    createdAt: new FormControl(Date.now()),
    lastModified: new FormControl(Date.now()),
    url: new FormControl('', Validators.required),
  });
  location = new FormGroup({
    lat: new FormControl(
      navigator.geolocation.getCurrentPosition(
        (position) => position.coords.latitude
      )
    ),
    lng: new FormControl(
      navigator.geolocation.getCurrentPosition(
        (postion) => postion.coords.longitude
      )
    ),
  });
  mapOptions = mapOptions;
  flagColors = flagColors;
  center = {
    lat: 52.5163,
    lng: 13.3777,
  };
  mapCenter = {
    lat: 52.509831294,
    lng: 13.375165166,
  };
  constructor(private flagService: FlagService) {}

  addFlag(center: google.maps.LatLngLiteral) {
    this.flags = [
      {
        location: {
          lat: center.lat,
          lng: center.lng,
        },
        options: {
          icon: {
            url: this.flagForm.value.url,
          },
          animation: 1,
        },
      },
    ];
  }
  async submit() {
    let flag: Flag = {
      createdAt: this.flagForm.controls['createdAt'].value,
      lastModified: this.flagForm.controls['lastModified'].value,
      uid: this.flagForm.controls['uid'].value,
      options: {
        icon: {
          url: this.flagForm.controls['url'].value,
        },
        animation: 2,
      },
      location: this.location.value,
    };
    await this.flagService.create(flag);
  }
  mapClick(event: google.maps.MapMouseEvent) {
    this.center = event.latLng!.toJSON();
    this.location.controls['lat'].setValue(this.center?.lat);
    this.location.controls['lng'].setValue(this.center?.lng);
    this.addFlag(this.center);
  }
  ngOnInit(): void {
    this.loading$ = this.flagService.loading$;
    this.error$ = this.flagService.error$;
    this.action$ = this.flagService.action$;
    this.flags$ = this.flagService.flags$;
  }
}

Next, edit flag-form.component.ts as follow:

<div class="flag-form">
  <form action="flag-form" [formGroup]="flagForm" (ngSubmit)="submit()">
    <label>Select Flag</label>
    <select name="flag-colors" id="" formControlName="url">
      <option *ngFor="let flag of flagColors" [value]="flag.url">
        {{ flag.title }}
      </option>
    </select>
  </form>
  <google-map
    width="100%"
    [options]="mapOptions"
    [center]="mapCenter"
    (mapClick)="mapClick($event)"
  >
    <map-marker
      *ngFor="let flag of flags"
      [position]="flag.location"
      [options]="flag.options"
    ></map-marker>
  </google-map>
  <div class="submit-buttom" matRipple>
    <button
      type="submit"
      mat-raised-button
      color="primary"
      (click)="submit()"
    ></button>
  </div>
</div>

Before you try to submit a flag, on the flag.service.ts, comment line 98:

// window.location.href = 'flags';

In the browser, navigate to localhost:4200 and open the console in the dev tools, it should look like this:

RxJS Streaming

Don’t worry about the design, we will change it in the next post, just click on the select flag dropdown, choose a flag and then click the button down the map on the left side, you should see in the console streams of the Firestore and the page store events:

Observable streaming

We can see in the console the BehaviorSubject steamed with the three values, change, prev, and next. As well as the Firestore observable streaming. Next, we will implement our user module, add the @angular/material, and create a layout for our application.

Wrapping up

In conclusion, Angular and RxJS help developers create powerful applications that can handle data streams. They allow developers to create Observables and Behavior Subjects that can be used to stream data and make it easier for users to access. By utilizing these tools, developers are able to create more efficient, powerful applications that can handle data streaming. As more developers embrace the power of Angular and RxJS, more possibilities will be available to create robust, feature-rich applications.

The full application code is available on GitHub.

chevron_left
chevron_right

Leave a comment

Your email address will not be published. Required fields are marked *

Comment
Name
Email
Website