Cómo crear una aplicación web full stack con Mapbox y Angular


En este tutorial veremos partes de una aplicación web full stack que emplea los opendata del Ayto de Madrid de meteo y calidad de aire.

Dicha aplicación emplea mapbox como librería de mapas, angular como framework de desarrollo y express y postgres en el back en entorno node.

En este tutorial sólo se muestra parte de los procesos relacionados con la generación de algún endpoint concreto, pintado de geometría en el front, utilización de un componente angular material, y visualización de datos con chartjs.

Lo primero de todo recordar que el modo de hacer uso de código externo en angular es a través de paquetes node, y no tanto añadir un <link> o <script> al html.

Para comenzar a trabajar con mapbox en angular recomiendo las lecturas:

https://medium.com/@mugan86/mapas-en-angular-8-con-mapbox-gl-185b157788af

https://medium.com/@timo.baehr/using-mapbox-in-angular-application-bc3b2b38592


1. Empezaremos por el back:

Los datos diarios contenidos en el postgres referentes a meteo y calidad de aire son todos los del año 2019 y año 2020 incluido marzo para todas las estaciones. La información relativa a las estaciones de igual modo se encuentran en el back.

Así, las rutas del back que dan acceso a esa info son:

						
app.use('/api/v1/airstations', airStations);
app.use('/api/v1/meteostations', meteoStations);

router.get('/', getAirStations);
router.get('/:id', getAirStationbyId);
router.get('/', getMeteoStations);
router.get('/:id', getMeteoStationbyId);


app.use('/api/v1/airdata', airData);
app.use('/api/v1/meteodata', meteoData);

router.post('/station/:stationid', getAirDataByStation);
router.post('/station/:stationid', getMeteoDataByStation);
						
					

Si os fijáis, hay rutas más generales (el modo en el que con express he compartimentado el acceso a la información) y otras más concretas, en las que se observa el nombre concreto del endpoint al que dan acceso en cada caso.

Y algunos de los endpoints son:

						
export async function getMeteoStations(req, res) {
    try {
        const response = await pool.query(
            `SELECT json_build_object(
                'type', 'FeatureCollection',
                'features', json_agg(
                    json_build_object(
                        'type',       'Feature',
                        'properties', json_build_object(
                            'codigo', codigo,
                            'codigo_cor', codigo_cor,
                            'estacion', estacion,
                            'direccion', direccion,
                            'lon_geogra', lon_geogra,
                            'lat_geogra', lat_geogra,
                            'altitud', altitud,
                            'v_viento', v_viento,
                            'dir_viento', dir_viento,
                            'temperatura', temperatura,
                            'hum_rel', hum_rel,
                            'presion', presion,
                            'rad_solar', rad_solar,
                            'precipitacion', precipitacion
                            
                        ),
                        'geometry',   ST_AsGeoJSON(geom)::json
                    )
                )
            ) AS geojson
            FROM meteostations`
        )
        res.status(200).json(response.rows[0]);

    } catch (e) { 
        console.log(e) 
    }
}

export async function getAirDataByStation(req, res) {
    const station = req.params.stationid;
    const { magnitud, ano, mes, dia } = req.body;

    try {
        const response = await pool.query(
            'SELECT h01, h02, h03, h04, h05, h06, h07, h08, h09, h10, h11, h12, h13, h14, h15, h16, h17, h18, h19, h20, h21, h22, h23, h24 FROM airdata WHERE estacion = $1 AND magnitud = $2 AND ano = $3 AND mes = $4 AND dia = $5', 
            [station, magnitud, ano, mes, dia]
        )
        console
        .log(response.rows);
        res.status(200).json(response.rows);
        
    } catch (e) {
        console.log(e)
    }
}
						
					

2. Front.

El servicio más importante de la aplicación es el MapboxGLService. En él convergen otros servicios y tiene diversos métodos que proveen funcionalidad a varios componentes.

Interesante son el addGeodata() que pinta en el componente mapa las geometrías y el addClick(), que permite la trasmisión de datos de un modo muy sencillo al dialog de angular material.

Al abrirse el dialog a través de la propiedad open, le pasamos la opción data, que en nuestro caso serán todas las propiedades que tiene la feature en la que se hace click.

Esas propiedades vienen del back y en el momento del evento click ya viven en el mapa y son el nombre, la altitud, las coordendas, y todos los parámetros que esa estación en concreto monitoriza.

						
import { Injectable, Inject } from '@angular/core';
import { environment } from '../../environments/environment';
import * as mapboxgl from 'mapbox-gl';
import  MapboxDirections from '@mapbox/mapbox-gl-directions/dist/mapbox-gl-directions';
import Geocoder from '@mapbox/mapbox-gl-geocoder';
import { StationsDataService } from './stations-data.service';
import { MatDialog } from '@angular/material/dialog';
import { PopupComponent } from '../components/shared/popup/popup.component';
import { Subscription } from 'rxjs';

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

  map: mapboxgl.Map;
  public mapbox = (mapboxgl as typeof mapboxgl);
  public style = 'mapbox://styles/mapbox/streets-v11';
  public lat = 40.415185;
  public lng = -3.694114;
  public zoom = 14;
  public pitch = 45;
  public bearing = -10.6;
  public scale = new mapboxgl.ScaleControl({
    maxWidth: 60,
    unit: 'meters'
  });

  private subscriptions: Subscription[] = [];
  public dialogRef;
  public stationClicked: any;
  public stationClickedProperties: any;

  constructor(
    public dialog: MatDialog,
    private stations: StationsDataService
    ) {
    (mapboxgl as any).accessToken = environment.mapBoxToken;
    (MapboxDirections).accessToken = environment.mapBoxToken;
    (Geocoder).accessToken = environment.mapBoxToken;
  }
  
  buildMap() {
    this.map = new mapboxgl.Map({
      container: 'map', //el tag del html #map
      style: this.style,
      zoom: this.zoom,
      center: [this.lng, this.lat],
      pitch: this.pitch,
      bearing: this.bearing
    });
    this.map.addControl(new mapboxgl.NavigationControl());
    this.map.addControl(this.scale);

    return this.map;
  }
  
  addGeodata() {
    this.map.once('data', () => {
      this.stations.getAirStations().subscribe(data => {
        this.addSource(this.map, 'airstations', data);
        this.addAirstationsLayer(this.map);
        this.addClick(this.map);
      })
      this.stations.getMeteoStations().subscribe(data => {
        this.addSource(this.map, 'meteostations', data);
        this.addMeteostationsLayer(this.map);
      })
    })
  }
  
  addSource(map, sourceName, data) {
    map.addSource(sourceName, {
      type: 'geojson',
      data
    })
  }
  
  addAirstationsLayer(map) {
    map.addLayer({
      id: 'airstationsLayer',
      type: 'circle',
      source: 'airstations',
      paint: {
        'circle-radius': {
          'base': 1.75,
          'stops': [
            [12, 10],
            [22, 180]
          ]
        },
        'circle-color': 'Black'
      }
    });
  }
  
  addMeteostationsLayer(map) {
    map.addLayer({
      id: 'meteostationsLayer',
      type: 'circle',
      source: 'meteostations',
      paint: {
        'circle-radius': {
          'base': 1.75,
          'stops': [
            [12, 10],
            [22, 180]
          ]
        },
        'circle-color': 'Green'
      }
    });
  }
  
  addClick(map) {
    map.on('click', (e) => {
      const features = map.queryRenderedFeatures(e.point, { layers: ['airstationsLayer', 'meteostationsLayer']});
      this.stationClicked = features[0].source;
      this.stationClickedProperties = features[0].properties;
      let dialogRef = this.dialog.open(PopupComponent, {
        data: features[0].properties
      })
    })
  }
						
					

Posteriormente, en el componente de tu aplicación que concibas como componente angular material dialog, debes inyectarle en el constructor el MatDialogRef para el randerizado en sí mismo y MAT_DIALOG_DATA para la trasmisión de datos. De esta forma este componente tiene a su disposición toda la información que va a mostrar.

						
constructor(
  @Inject(MAT_DIALOG_DATA) public data: any,
  public stationsService: StationsDataService,
  private mapboxservice: MapboxGLService,
  public dialogRef: MatDialogRef<PopupComponent>,
  public dialog: MatDialog
) { }
						
					

3. Chartjs.

Chartjs es una librería para visualización de datos muy sencilla. Otra puede que un nivel superior es plotly. Por encima queda D3.js, y seguro que otras muchas.

El componente que llamo chart es hijo de otro componente, bigpopup. Eso explica la trasmisión de propiedades entre el padre y el hijo (@Input, el hijo recibe del padre datos).

En este caso una propiedad que el padre comparte llamada “data”, es ahora llamada en el hijo “dataForChart”. De igual modo, otra propiedad que el padre le comparte como “dataSecondStation”, es nombrada en el hijo como “dataForChartSecond”.

De este modo, el componente chart recibe los dos objetos de datos que se encargará de pintar y comparar en un gráfico de línea.

A continuación, al inicio del ciclo de vida ngOnChanges de este componente, se trata de desacoplar para cada objeto fuente de datos (dataForChart o dataForChartSecond) las keys por un lado y los values por otro e inicializar las variables “xlabels” e “ylabes” anteriormente declaradas. Ahora estaremos en condiciones de realmente tener las variables necesarias para pintar el eje de abscisas, el de coordenadas y los propios datos.

						
import { Component, OnInit, Input, OnChanges } from '@angular/core';
import { Chart } from 'node_modules/chart.js';
import { MapboxGLService } from '../../../services/mapbox-gl.service';

@Component({
  selector: 'app-chart',
  templateUrl: './chart.component.html',
  styleUrls: ['./chart.component.scss']
})
export class ChartComponent implements OnChanges {

  @Input('data') dataForChart: any;
  @Input('dataSecondStation') dataForChartSecond: any;

  @Input() stationTwo: any;

  public xlabels: any[];
  public yvalues: any[];

  //la x como son las mismas horas utilizo la xlabels variable
  public yvaluesSecondStation: any[];

  constructor(
    private mapboxService: MapboxGLService
  ) { }

  ngOnChanges(): void {
    if (Array.isArray(this.dataForChart) && this.dataForChart.length > 0) {
      this.xlabels = Object.keys(this.dataForChart[0]);
      this.yvalues = Object.values(this.dataForChart[0]);
    }
    if (Array.isArray(this.dataForChartSecond) && this.dataForChartSecond.length > 0) {
      this.yvaluesSecondStation = Object.values(this.dataForChartSecond[0])
    }

    var mySuperChart = new Chart("myChart", {
      type: 'line',
      data: {
        labels: this.xlabels,
        datasets: [{
          label: this.mapboxService.stationClickedProperties.estacion,
          data: this.yvalues,
          fill: false,
          backgroundColor: 'rgba(0, 0, 0, 0.2)',
          borderColor: 'rgba(0, 0, 0, 1)',
          borderWidth: 1
        }, {
          label: this.stationTwo,
          data:  this.yvaluesSecondStation,
          fill: false,
          backgroundColor: 'rgba(0, 0, 0, 0.2)',
          borderColor: 'red',
          borderWidth: 1
        }]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        title: {
          display: true,
          text: 'Parameter variation during the day',
          position: 'top'
        },
        legend: {
          display: true,
          align: 'start',
          position: 'top',
          labels: {
            boxWidth: 20,
            fontSize: 10,
          }

        },
      }
    })

  }
}
						
					


Sobre el Autor


Hugo Herrador Carrasco

Practico el tiro con arco y me gusta el fototrampeo y la ornitología. He vivido en Ecuador y Holanda y estoy seguro que en Colombia sería feliz. He visto a los salmones remontar y creo que es un gran método de filosofía de vida. Lo cierto es que hay otros mundos y todos están en este. #Geospatial


¿Eres desarrollador? Únete a la red