Bosques de Europa

Representando +500,000 árboles con Google Maps y Deck.gl

Ya sea por el requerimiento de un proyecto o explotar los límites de Google Maps, una de las dificultades más grandes que tenemos como desarrolladores al utilizar esta herramienta es la necesidad y capacidad de poder desplegar miles o cientos de miles de puntos dentro de un mapa personalizado. ¿Es realmente posible?.

Google Maps y su API ha ido evolucionado a lo largo de los últimos años y con ello ha traído conceptos nuevos tales como Custom Overlays, los cuales permiten extender la capacidad de los mapas que generamos y brindan la oportunidad a proyectos como deck.gl de integrar sus propios desarrollos.

En este artículo me gustaría contar mi experiencia para lograr representar 588,983 árboles dentro de Google Maps, así que te invito a tomar tu bebida favorita mientras lees este post (☕|🥤|🍺|🍷)

Contenido

La inspiración

El año pasado tuve la oportunidad de presenciar en dos ocasiones una plática de Alex Muramoto titulada “Impresionantes visualizaciones de datos web con Google Maps Platform y deck.gl” si no tuviste la oportunidad de asistir a algunos de los DevFest en los que estuvo presente puedes encontrar una grabación de su plática en este link: https://bit.ly/alexsgmapdeckgl.

Durante la charla Alex nos muestra diferentes ejemplos de cómo extender la capacidad de Google Maps con Deck.gl, nos brinda las bases para crear mapas personalizados más allá del marcador que estamos acostumbrados a utilizar, podemos agregar puntos, arcos, y visualizaciones animadas en tiempo real sobre segmentos del mapa, así que después de un tiempo ocupado por cuestiones laborales, me anime a realizar un experimento con estas tecnologías.

Eligiendo el problema

Investigando encontré diferentes sets de datos bastantes completos en páginas como Paris Data o el Portal Europeo de Datos y también diferentes artículos científicos que abordaban el tema de visualización geoespacial, uno de estos ellos llamó en especial mi atención, “EU-Forest, a high-resolution tree occurrence dataset for Europe” 1, el artículo trata, sobre la curación digital de información para lograr generar un dataset que permita hacer pública la distribución de europea de especies arbóreas, nos brindan el dataset completo utilizado en su publicación, el sistema de coordenadas utilizado y nos muestra diferentes resultados de visualización.

Distribución espacial de todas las ocurrencias presentes en los bosques de la UE. Distribución espacial de todas las ocurrencias presentes en los bosques de la UE.

Así que tomando estos puntos en cuenta, resulta bastante interesante replicar el experimento dentro de Google Maps y Deck.gl.

Sistemas de coordenadas, más que latitud y longitud

Dataset

Existen diferentes archivos dentro de la publicación, pero el que tomaremos2 como base para la visualización de datos, es aquel que se encuentra clasificado y curado respecto a las especies de los árboles, dicho archivo se encuentra en formato CSV y como podemos leer en los metadatos, la representación de los valores X y Y se encuentran dados en coordenadas de la referencia ETRS89-LAEA 🤨, ooook!.

La mayoría de las librerías o mapas que integramos dentro de nuestro código regularmente, nos han enseñado a utilizar y hacer referencia a las posiciones con los valores de latitud y longitud, así que el primer reto que se presentó al procesar el dataset fue entender y transformar estos valores a uno que haga sentido al sistema utilizado en Deck.gl o Google Maps.

Haciendo una pequeña búsqueda en Google, descubrí que el valor ETRS89-LAEA también tiene un equivalente: EPSG:3035.

EPSG

El EPSG es la abreviatura correspondiente a “European Petroleum Survey Group”, este grupo se encargó de generar diferentes sets de datos, para poder tener referencias espaciales que pudieran ser de aplicación global, regional, nacional o local, así que si quisiéramos hacer referencia o uso a un dataset que utilice latitud y longitud como valores tendríamos que buscar el código EPSG correspondiente.

De los primeros recursos que encontré y que permitían buscar estas referencias de una manera sencilla, es la página epsg.io, incluso dentro de la misma existe un servicio que nos permite hacer la transformación entre sistemas utilizando un formulario.

Transformando coordenadas


Inspeccionado el código, me doy cuenta que utiliza una librería llamada, pro4JS! Bingo! es lo que necesito para comenzar a convertir los datos 😁, además gracias a su formulario también descubro que las coordenadas tradicionales entran dentro del código EPSG:4326.

proj4JS

pro4JS, es un proyecto Open Source basado en proJ, el cual permite convertir coordenadas entre diferentes sistemas, al ser de un nicho tecnológico muy específico, la documentación puede resultar en ocasiones escasa y comprender del todo los cálculos que se efectúan, puede resultar difícil si no estás inmerso en el tema o lo usas de manera habitual. Aun así, el uso de pro4JS parece simple al inicio, se puede instalar vía npm o yarn, y la documentación en Github nos indica que su uso es el siguiente:

  proj4(fromProjection[, toProjection, coordinates])

Los valores que necesitaremos hacen referencia a las proyecciones 3, una proyección es la operación de coordenadas que permitirán obtener la equivalencia de cada uno de los sistemas, dentro del proyecto proJ existe una lista y explicación más detallada de esta parte, en otra ocasión escribiré como se forman los valores de cada las proyecciones. Mientras tanto, el ejemplo del valor necesario para definir el código EPSG:3035 es el siguiente:

[
  'EPSG:3035',
  '+proj=laea +lat_0=52 +lon_0=10 +x_0=4321000 +y_0=3210000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs ',
];

Resulta un poco complicado al inicio, comprender u obtener los valores necesarios para la proyección, Razón por la cual generé un repositorio con una lista en Javascript que permite obtener los valores usando como índice el valor EPSG. La lista contiene 6137 valores, que se pueden utilizar en las conversiones y de la que además se puede leer la lista completa en el archivo README del proyecto, para utilizarla la lista con proj4 el código se escribiría de la siguiente manera:

 const proj4 = require("proj4")
 const proj4list = require("./list.min");
 proj4.defs([
   proj4list["EPSG:3035"],
   proj4list["EPSG:4326"]
 );
 proj4("EPSG:3035", "EPSG:4326", [ 4305500, 2674500 ]);

Con la capacidad de convertir cada uno de los valores de X a Y, ya podemos crear un dataset con las coordenadas con los valores de latitud y longitud, nuestro script para convertir el CSV a un JSON seguiría entonces los siguientes pasos:

  • 📃 Leer cada fila del archivo CSV original.
  • 🗺 Convertir X y Y a latitud y longitud.
  • 🌲 Sustituir la especie por un valor numérico para poder ser mapeado más adelante, esas especies las almacenaremos en un archivo por separado.

De esta manera, podremos generar un dataset con la siguiente estructura:

[
  {
    "specie": 70,
    "lat": 35.01966518543305,
    "lng": 32.6269824790667
  }, ...
]

Si desean hacer uso de los dataset, pueden encontrar la información clasificada también por países, dentro del siguiente repositorio : Datasets : EU-Forest

Obteniendo el color de los árboles

La visualización que necesitamos, podría funcionar solo con la posición de cada uno de los puntos de los árboles, podríamos generar directamente la gráfica como la imagen presentada en el artículo. Como usuarios que consumen información, el representar los datos de manera correcta, genera una mejor experiencia y entendimiento de lo que estamos viendo en nuestros monitores.

Así que con esa premisa, comencemos a trabajar en esta parte 🤓.

Para resolver esto, la idea es obtener un par de imágenes a partir de Google Search Images, unirlas y obtener el color dominante en las imágenes fusionadas.

Encontré la librería images-scraper para usarla con node.js, que nos permite obtener un Array con las rutas de imágenes a partir de una query utilizando Puppeteer.

const scraper = require('images-scraper');

const google = new scraper({ puppeteer: { headless: true } });

function searchAndAnalize(term, limit) {
  /*
    results [
      {
        url: 'https://...',
        source: 'https://...',
        description: '...'
      },
      ... 
    ]
  */
  return new Promise(async (resolve, reject) => {
    const results = await google.scrape(term, limit);
  });
}

Por cada elemento en el Array, descargaremos cada uno de los archivos, construiremos una función que nos permite almacenar los archivos en un directorio, y a su vez, permita alamacenar las rutas en otro Array para ser procesado más adelante. La función a través de request se encarga de mandar una petición y procesar la respuesta con fs.

const fs = require('fs');
const request = require('request');

async function downloadImage(url) {
  try {
    return new Promise((resolve, reject) => {
      request.head(url, function (error, res, body) {
        if (error) {
          reject(error);
        }
        let extension =
          res.headers['content-type'] === 'image/jpeg' ? 'jpg' : 'png';
        let file_name = `${collectionPath}/${new Date().getTime()}.${extension}`;
        request(url)
          .pipe(fs.createWriteStream(file_name))
          .on('close', () => {
            resolve(file_name);
          });
      });
    });
  } catch (error) {
    console.log('error in', url);
  }
}

Con esta función elaborada, podemos descargar las imágenes y unirlas utilizando merge-img, ya que no controlamos las dimensiones de la imagen seleccionaremos PNG como output para poder ser analizado.

function searchAndAnalize(term, limit) {
  return new Promise(async (resolve, reject) => {
    const results = await google.scrape(term, limit);

    let images = await Promise.all(
      results.map(async (result) => {
        return await downloadImage(result.url);
      })
    );

    let img = await mergeImg(images);
    const output = `${collectionPath}/result_${new Date().getTime()}.png`;

    img.write(`${output}`, async () => {
      //AQUI VAMOS ANALIZAR LA IMAGEN
    });
  });
}

El resultado de cada imagen creada, será muy parecida a la siguiente:

Hojas

Ahora utilizaremos Color Thief, una librería bastante popular para obtener los colores promedio con Javascript, lo único que tenemos que asignar como parámetro es la imagen a analizar.
img.write(`${output}`, async () => {
  resolve(await colorThief.getColor(output));
});

Nuevamente generé un repositorio completo con esta funcionalidad, pueden encontrarlo aquí. Instalando todas las dependencias y siguiendo las instrucciones podemos ejecutar un comando en nuestra terminal que funciona de la siguiente manera:

node example.js palette "Batman" 10

Los valores correspondientes a los colores significativos que produce este script, se puede encontrar en el siguiente archivo, en este se encuentran las 242 especies de árboles procesadas.

Y ahora ¿Qué es eso deck.gl?

Deck.gl es una de las herramientas Open source, que está cambiando la manera en la que podemos visualizar datos dentro de mapas, creada por el equipo de visualización de Uber, este Framework a través del uso de WebGL2 y que por consecuencia, hace un mejor uso de nuestro hardware, permite explorar la información de grandes conjuntos de datos mediante el renderizando y superposición de capas dentro de nuestro navegador, permite al desarrollador la personalización de capas existentes o añadir nuevas en caso de ser necesario.

Primeros pasos con deck.gl

Ok, ya entendimos que deck.gl funciona a base de capas, ahora hagamos uso de las mismas para generar una visualización.

Una de las características que me gusta de deck.gl, paradójicamente es su independencia del sistema de mapas que prefieras, puede ser utilizado con tecnologías como Google Maps, Mapbox, Arcgis, e inclusive puede sin nada como que sirva como marco.

Para hacer este primer acercamiento a deck.gl usaremos dos de los dataset creados por geojson.xyz.

Podemos utilizar deck.gl en diferentes formas, una de ellas es integrando directamente el script dentro de nuestro HTML.

<script
  type="text/javascript"
  src="https://unpkg.com/deck.gl@latest/dist.min.js"
></script>

Cuando agregamos el javascript sin webpack o algún manejador de módulos, tenemos que iniciar en alguna variable o constante los métodos que utilizaremos:

const { DeckGL, GeoJsonLayer, ArcLayer } = deck;

Iniciaremos también tres constantes, dos de ellas para el manejo de los dataset y otra indicando un punto central correspondiente al Aeropuerto de la Ciudad de México.

const COUNTRIES =
  'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_50m_admin_0_scale_rank.geojson';
const AIR_PORTS =
  'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_10m_airports.geojson';

const INITIAL_VIEW_STATE = {
  latitude: 19.43,
  longitude: -99.08,
  zoom: 4,
  bearing: 0,
  pitch: 30,
};

Para iniciar deck.gl, ahora necesitamos crear una nueva instancia de la función principal, agregando como parámetros un estado inicial, un controlador y en un array correspondiente a las capas en el que iremos mostrando la información.

new DeckGL({
  initialViewState: INITIAL_VIEW_STATE,
  controller: true,
  layers: [
    // ... AQUI IRAN LAS CAPAS DE DATOS
  ],
});

Dado que nuestros datasets ya se encuentra en formato JSON, podemos utilizar GeoJsonLayer para crearlos de manera rápida, en este caso comenzaremos agregando una capa que corresponda a los países.

...
layers : [
  new GeoJsonLayer({
    id: 'base-map',         //ID de la capa
    data: COUNTRIES,        //Dataset
    stroked: true,          //Indicamos si tiene borde
    filled: true,           //Indicamos si esta iluminado
    lineWidthMinPixels: 1,  //Ancho del pixel
    opacity: 0.4,           //Opacidad
    getLineColor: [154, 154, 154],  //RGB para la linea
    getFillColor: [242, 242, 242]   //RGB para el iluminado
  }),
]
...

En pantalla tendremos un resultado como se muestra en la siguiente imagen:

Mapa La capacidad de deck.gl de poder manejar capas, nos permite crear nuestros propios mapas.

Con el mapa creado, ahora podemos agregar una capa correspondiente a los aeropuertos existentes:

...
layers : [
   ...,
    new GeoJsonLayer({
      id: 'airports',
      data: AIR_PORTS,
      filled: true,
      pointRadiusMinPixels: 2,
      pointRadiusScale: 2000,
      getRadius: f => 11 - f.properties.scalerank,
      getFillColor:[21, 192, 25],
      pickable: true,
      autoHighlight: true,
      onClick: info =>
        info.object && alert(`${info.object.properties.name}`)
    })
]
...
Aeropuertos Tenemos dos capas superpuestas, una correspondiente al mapa y otra a los aeropuertos.

Ahora agregaremos una capa para indicar la conexión entre diferentes aeropuertos, para eso utilizaremos ArcLayer, la idea es tener un punto del cual se originarán las conexiones y desde ahí lanzar arcos que conectan con el resto de los aeropuertos.

new ArcLayer({
  id: 'arcs',
  data: AIR_PORTS,
  dataTransform: (d) => d.features.filter((f) => true),
  getSourcePosition: (f) => [-99.08, 19.43],
  getTargetPosition: (f) => f.geometry.coordinates,
  getSourceColor: [238, 157, 30],
  getTargetColor: [21, 192, 25],
  getWidth: 1,
});

El resultado de las 3 capas sería el siguiente:

En este iframe puedes interactuar con el resultado.

También podemos modificar el comportamiento de los datos relacionados con la propiedad de dataTransform, imaginemos que necesitamos mostrar solo la conexión entre México y los aeropuertos existentes en la franja que se muestra en la siguiente imagen.

mapa del mundo Límite entre la longitud 90 y 93.

Para ello, modificaremos el atributo de la siguiente manera:

const MAX = -93, MIN = -90;
...
new ArcLayer({
    ...
    dataTransform: d => d.features.filter(f => f.geometry.coordinates[0] < MIN && f.geometry.coordinates[0] > MAX),
  ...
})
...
En este iframe puedes interactuar con el resultado.

Integrando deck.gl y Google Maps

Al ser uno de los sistemas de mapas más populares para representar información, Google Maps puede ser utilizado junto con deck.gl para el manejo de capas, leyendo el código fuente nos podemos dar cuenta que utiliza Custom Overlays para lograr la integración.

...
export default class GoogleMapsOverlay {
  constructor(props) {
    this.props = {};
    this._map = null;

    const overlay = new google.maps.OverlayView();
    overlay.onAdd = this._onAdd.bind(this);
...

Entendiendo Custom Overlay

Podemos definir a los Custom Overlays4 como objetos que estarán en el mapa y que están vinculados a coordenadas dadas por una latitud y longitud. Una de las principales características, es que se moverán al hacer zoom o arrastrar el mapa a otra posición.

Utilizar los Custom Overlays requiere principalmente de 5 pasos:

1.- Inicializar el prototype de un objeto con google.maps.OverlayView()

USGSOverlay.prototype = new google.maps.OverlayView();

2.- Crear un constructor para nuestro objeto y así poder iniciar los parámetros requeridos.

function USGSOverlay(bounds, image, map) {
  this.bounds_ = bounds;
  this.image_ = image;
  this.map_ = map;
  this.div_ = null;
  this.setMap(map);
}

3.- Implementar el método onAdd en el prototype creado. En esta parte, nos encargaremos de generar el contenedor de la imagen que utilizaremos.

USGSOverlay.prototype.onAdd = function () {
  var div = document.createElement('div');
  div.style.borderStyle = 'none';
  div.style.borderWidth = '0px';
  div.style.position = 'absolute';

  var img = document.createElement('img');
  img.src = this.image_;
  img.style.width = '100%';
  img.style.height = '100%';
  img.style.position = 'absolute';
  div.appendChild(img);

  this.div_ = div;

  var panes = this.getPanes();
  panes.overlayLayer.appendChild(div);
};

4.- Implementar el método draw en el prototype.

USGSOverlay.prototype.draw = function () {
  var overlayProjection = this.getProjection();
  var sw = overlayProjection.fromLatLngToDivPixel(this.bounds_.getSouthWest());
  var ne = overlayProjection.fromLatLngToDivPixel(this.bounds_.getNorthEast());

  var div = this.div_;
  div.style.left = sw.x + 'px';
  div.style.top = ne.y + 'px';
  div.style.width = ne.x - sw.x + 'px';
  div.style.height = sw.y - ne.y + 'px';
};

5.- Y finalmente implementar el método onRemove en el prototype.

USGSOverlay.prototype.onRemove = function () {
  this.div_.parentNode.removeChild(this.div_);
  this.div_ = null;
};

Una vez que tenemos implementado nuestro Custom Overlay podemos invocarlo dentro la función que utilizamos para iniciar nuestro mapa.

En nuestro método necesitamos definir el área que ocupará nuestra imagen, para ello utilizaremos el método LatLngBounds, el cual recibe 2 parámetros: LatLng southwest y LatLng northeast, esto quiere decir que para indicar la posición correctamente de nuestra imagen, primero tenemos que indicar la coordenada correspondiente al lado inferior izquierdo en que se posicionará, y como segundo la coordenada superior derecha.

function initMap() {
  //Nuestro mapa principal
  var map = new google.maps.Map(document.getElementById('map'), {
    zoom: 11,
    center: { lat: 19.487711, lng: -99.008554 },
  });
  //Indicamos el área que ocupará la imagen
  var bounds = new google.maps.LatLngBounds(
    //Suroeste o inferior izquierdo
    new google.maps.LatLng(19.389876, -99.1009),
    //Noreste o superior derecho
    new google.maps.LatLng(19.599925, -98.858176)
  );
  //Imagen que agregaremos al mapa
  var srcImage = './map.png';
  //Nuestro overlay recive el área, la imagen y el mapa
  overlay = new USGSOverlay(bounds, srcImage, map);
}

En el siguiente ejemplo, podemos ver el resultado de sobreponer una imagen correspondiente al área del lago de Texcoco5 en Ciudad de México.

En este iframe puedes interactuar con el resultado.


GoogleMapsOverlay y nuestro dataset de árboles.

Bien! Ha llegado la hora de aplicar todo lo que hemos aprendido y trabajado hasta este momento, ya contamos con nuestro dataset curado de las especies de los árboles y con los colores que los representarán, además entendimos un poco más del funcionamiento de deck.gl y Custom Overlays con un par de ejemplos muy sencillos.

Como mencionamos, deck.gl tiene un método propio para hacer uso de los Custom Overlays, el método se llama GoogleMapsOverlay y nos ahorra mucho del trabajo que vimos en la sección anterior.

import { colors } from './colors'; //Colores recolectados

async function init() {
  await loadScript();
  GMAP = new google.maps.Map(MAP, MAP_PROPS);

  deckGL_overlay = new GoogleMapsOverlay();
  //Indicamos el mapa a utilizar
  deckGL_overlay.setMap(GMAP);
  //Agregamos la capa o capas a utilizar, siempre en un array
  deckGL_overlay.setProps({ layers: [await getLayer()] });
}

Como podemos ver en la implementación, lo único que tenemos que indicar como propiedad del objeto son las capas que queremos visualizar. Deck.gl permite manejar multiples capas a través de un Array como parámetro.

Ahora, para poder llamar la capa correspondiente a los árboles que procesamos anteriormente, haremos uso de la función getLayer, en esta, obtendremos los datos directamente del archivo JSON que contiene, los puntos de cada elemento, además iremos indicando el color a visualizar respecto a la especie que se encuentre en la información recibida.

async function getLayer(layer = 'all') {
  //Realizamos la petición de información
  let request = await fetch(
    `https://dataset-euforest.storage.googleapis.com/country_${layer}.json`
  );
  let data = await request.json();

  return await new ScatterplotLayer({
    id: 'scatterplot-layer',
    data: data, //Esta es la inforamción de los árboles
    opacity: 1,
    stroked: false,
    filled: true,
    radiusScale: 20,
    radiusMinPixels: 1,
    radiusMaxPixels: 100,
    lineWidthMinPixels: 1,
    //Obtenemos la latitu y la longitud
    getPosition: (d) => [d.lng, d.lat],
    getRadius: (d) => 50,
    //A través del índice de la especie, retornamos el color
    getFillColor: (d) => colors[d.specie],
    getLineColor: (d) => [30, 30, 30],
  });
}

El resultado se puede apreciar en el siguiente iframe o visitando la URL: https://eu-forest.mapsviz.com.

Mapa de los bosques de Europa usando deck.gl y Google Maps.

Si deseas, puedes visitar el repositorio con el código completo de la implementación y ver el resto del desarrollo.

Conclusiones

Sin lugar a duda, la dupla que hacen deck.gl y Google Maps, nos permiten generar experiencias y visualización de datos de una manera muy simple y relativamente con poco poder computo. Hoy en día, contamos con las herramientas que nos permiten hacer un análisis Geoespacial muy diferente al que estábamos acostumbrados. Si bien existen tareas que ya se encuentran resueltas en artículos científicos o por software especializado, podemos darle un toque diferente y “democratizarlo” gracias a la web. En estos tiempos en que la información se encuentra en diferentes puntos y se genera de manera constante, el permitir que otras personas comprendan la magnitud de la misma, es uno de los deberes que tenemos como Ingenieros de Software.

Para mí fue un camino divertido, en el que aprendí sobre diferentes maneras que hemos desarrollado para medir y representar nuestra ubicación, incluyendo las cosas que nos rodean de manera cercana o global. Espero que este artículo, sea un primer paso que te inspire, alimente tu curiosidad y sirva de referencia para crear una nueva visualización interesante.

Referencias y enlaces

1.- Mauri, A. et al. EU-Forest, a high-resolution tree occurrence dataset for Europe. Sci. Data 4:160123 doi: 10.1038/sdata.2016.123 (2017).

2.- Mauri, A., Strona, G., & San-Miguel-Ayanz, J. Figshare https://dx.doi.org/10.6084/m9.figshare.c.3288407 (2016).

3.- Projections - PROJ

4.- Custom Overlays Google Maps Documentation

5.- All Things living, all things dead - Cartografías del Lago de Texcoco

6.- Sitio geojson.xyz