¿Cómo generar un mapa de calor (heatmap) en Cesium?

¿Cómo generar un mapa de calor (heatmap) en Cesium?

 

Introducción

Por defecto, Cesium no es capaz de generar mapas de calor, por lo que es necesario utilizar soluciones de terceros para conseguir implementar dicha funcionalidad al visor.

Existe una librería llamada heatmap.js que calcula y representa mapas de calor a partir de diferentes tipos de entrada, como la posición del ratón en pantalla, donde se hace clic o incluso a partir de coordenadas del propio canvas del navegador.

A partir de esta librería y de la idea de representar puntos en el canvas, se ha desarrollado otra librería llamada CesiumHeatmap.js que permite generar mapas de calor en el propio visor partiendo de las coordenadas de los puntos, y de un valor representativo del peso del punto.

Al terminar este tutorial, a partir de una ontología de puntos se podrá generar un mapa de calor similar a este:

 

¡Eh! Que no es una foto, que es un mapa interactivo... prueba a navegar por él.

Instalación de la librería

El primer paso consistirá en descargar y descomprimir la librería, o clonar el repositorio (https://github.com/manuelnas/CesiumHeatmap.git) en la carpeta del proyecto donde se contenga el visor. 

El archivo descargado/clonado cuenta con los siguientes archivos:

  • CesiumHeatmap.js: la librería para agregar mapas de calor usando heatmap.js (la incluye) y la funciones Cesium.Entity.Rectangle o Cesium.SingleTileImageryProvider, y es la que se usará en este tutorial.

  • HeatmapImageryProvider.js: otra librería para agregar mapas de calor usando también heatmap.js (también la incluye) mediante la personalización de la función Cesium.ImageryProvider, y que de momento no se verá.

  • LICENSE: documento genérico con el copyright de las librerías.

  • package.json: archivo con la dirección del repositorio.

  • README.md: documento con la descripción de las librerías, así como el ejemplo de uso de CesiumHeatmap.js.

Para utilizar la librería en el visor de Cesium, hay que insertar la librería de CesiumHeatmap.js a continuación de la llamada de la librería de Cesium.js, tal como se muestra a continuación:

Insertar la librería CesiumHeatmap.js
<script src="https://www.onesaitplatform.online/web/Cesium160/Cesium.js"></script> <script src="js/heatmap/CesiumHeatmap.js"></script> <style> @import url(https://www.onesaitplatform.online/web/Cesium160/Widgets/widgets.css);

Hecho esto, ya se puede empezar a generar mapas de calor.

Filosofía de funcionamiento

A la hora de generar un mapa de calor se necesitan una serie de entidades de tipo punto (no funciona con líneas o polígonos), con unas coordenadas geoespaciales definidas (normalmente longitud y latitud), así como un campo que contenga el valor o peso de cada punto respecto al resto de puntos.

Para este tutorial se han creado una serie de puntos, a cada uno de los cuales se les ha asignado un valor. Dichos puntos representados en un mapa junto a sus valores se verían de la siguiente manera:

Las coordenadas y valores de todos estos puntos -30 en total- se encuentran recogidos en una ontología cargada en la Plataforma y servida como un servicio API REST. Cada entidad mantiene la siguiente estructura:

Ejemplo de la estructura de una entidad de la ontología
{ "type": "Feature", "properties": { "id": 2, "value": 22 }, "geometry": { "type": "Point", coordinates": [ -15.432677385995223, 28.138219879738312 ] } }

En dicha estructura, se encuentran definidos los campos de longitud (geometry.coordinates[0]), latitud (geometry.coordinates[1]) y el valor asignado al punto (properties.value).

 

heatmapSampleData

¿Cómo? ¿Que aun no sabes cómo se genera una ontología y se sirve como un servicio API REST? Estás de suerte, aquí mismo lo explicamos. Y si te animas a probar a crear tu API REST para este ejemplo, aquí tienes el archivo JSON: heatmapSampleData.json

Además de los puntos con valores, para generar el mapa de calor es preciso definir la zona de estudio, entendiendo como tal el rectángulo que delimita los puntos que se tendrán en consideración. Así, para el grupo de puntos del tutorial, un posible rectángulo delimitador podría ser el siguiente:

 

Rectángulo delimitador

Este sería el rectángulo al que se hace referencia cuando se dice que esta librería hace uso de la función Cesium.Entity.Rectangle

 

De dicho rectángulo lo que interesa conocer no es la geometría del mismo, sino las coordenadas de los límites superior, inferior, izquierdo y derecho. Esto, aunque es sencillo de obtener con un programa GIS, puede no serlo tanto si hay que hacerlo a mano. Una recomendación es coger el punto que se encuentre más 'arriba', y aumentar el valor de su coordenada de latitud (geometry.coordinates[1]), para que quede algo más al norte. Repitiendo esto para los otros lados, se obtendrían las coordenadas.

 

Otra posibilidad algo más sencilla pero técnica, consistiría en poder dibujar dos puntos; uno correspondiente al vértice superior izquierdo del rectángulo, y otro al vértice inferior derecho. Con esos dos puntos se obtienen cuatro coordenadas en total, correspondientes a los cuatro bordes del diagrama. Veámoslo en el siguiente ejemplo:

heatmapSampleData

Prueba a dibujar algún punto en geojson.io y verás aparecer sus coordenadas en el editor de JSON de la parte derecha del mapa. Recuerda que la primera coordenada corresponde con la longitud (este-oeste) y la segunda con la latitud (norte-sur).

 

Y ya está; teniendo esta idea en mente, generar mapas de calor en Cesium es algo bastante sencillo.

Uso de la librería (o cómo hacer ya por fin un mapa de calor en Cesium)

Vista toda la teoría, pasemos a generar el mapa de calor como el del ejemplo de antes.

En primer lugar, se procederá a obtener los datos de las entidades de puntos, que se encuentran en una ontología servida por un servicio REST. Para ello se hará uso de AJAX, por lo que en el <head> de la página web se introducirá una llamada a una librería de jQuery.

Inclusión de la librería de jQuery
<script src="https://www.onesaitplatform.online/web/Cesium160/Cesium.js"></script> <script src="js/heatmap/CesiumHeatmap.js"></script> <script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous" ></script>

Seguidamente, en el código javascript del visor de Cesium, se hará la llamada al servicio REST mediante el siguiente código:

Llamada al servicio REST mediante AJAX
jQuery.ajax({ /** URL al servicio REST */ url: 'https://www.onesaitplatform.online/api-manager/server/api/v1/api_tuto_cesium_heatmap/getPointFeatures', /** Encabezado con la token de acceso al API */ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-OP-APIKey': 'token_del_usuario' /** Tipo de servicio del API */ type: 'GET', /** Contenido que ofrece la API; en este caso la ontología, en formato JSON */ contentType: 'application/json', /** En caso de que el token sea válido, se recupera la información */ success: function(jsonData) { }, /** En caso de que el token no sea válido, se lanza el error */ error: function(data, errorThrown) { alert('Se ha producido un problema con la lectura de la ontología.') } })

 

Uso de ontología

Ten en cuenta que la URL hace referencia al API generado para este ejemplo. Si quieres generar un mapa de calor con tus propios datos, debes de crear tu propio servicio REST, e incluir la URL correspondiente.

 

Uso de token personal

Acuérdate de cambiar donde pone 'token_del_usuario' (línea 10 del código anterior) por tu token personal.

 

Organización del código

Todo el código que se vaya a introducir de aquí en adelante se hará dentro de la función que devuelve el jsonData (a partir de la línea 20 del código anterior).

A continuación, se definirá una variable que contendrá un objeto con los límites del rectángulo antes definido:

Definición de los límites del rectángulo
/** Se definen los límites del área de estudio */ let extent = { west: -15.4522, south: 28.1276, east: -15.417, north: 28.1534 }

También se creará otra variable que definirá las propiedades de configuración del mapa de calor. De momento, se indicarán las más básicas, y más adelante se entrará en detalle cómo personalizar el mapa de calor.

Configuración de las propiedades del mapa de calor
/** Se definen las propiedades del mapa de calor */ let heatmapSetup = { radius: 150, maxOpacity: 0.8, minOpacity: 0.2, blur: 0.75 }

 

Opciones de configuración

Si tienes interés, puedes ver todas las opciones de personalización genérica en la documentación de heatmap.js

Seguidamente se generará una instancia de CesiumHeatmap utilizando CesiumHeatmap.create(). Dicha instancia se configura con tres miembros:

  • El visor del mapa: entendiéndose como tal el objeto ‘viewer’ que define el visor.

  • Los límites de interpolación: que corresponde con los límites del rectángulo antes definido.

  • Una serie de propiedades de configuración: que acabamos de crear.

Así, una instancia básica quedaría definida como:

Creación de una instancia de CesiumHeatmap
/** Se genera la instancia del mapa de calor */ let heatMap = CesiumHeatmap.create(viewer, extent, heatmapSetup)

 

Por último, sólo queda añadir los datos al visor, indicando el formato en el que se encuentran las coordenadas del visor. Existen dos posibilidades:

  • setData(): para datos en coordenadas X e Y (del estilo UTM).

  • setWGS84Data(): para datos en coordenadas de longitud y latitud.

 

En ambos casos, la función recibirá de parámetros de entrada:

  • Valor mínimo y máximo de la interpolación: para un rango de valores dados, cuanto más se aproxime el valor mínimo y máximo a los valores mínimos y máximos de los puntos, mayor será el efecto visual de diferenciación en la interpolación (los colores serán más extremos).

  • Listado de entidades de puntos: con sus valores de pesos.

 

Para el caso de los datos del ejemplo, puesto que se encuentran definidos con coordenadas de longitud y latitud, quedaría como:

Añadir el mapa de calor al visor
/** Se añade el mapa de calor al visor, considerando los datos de entrada como de longitud y latitud (WGS84) */ heatMap.setWGS84Data(15, 26, data)

 

En este caso, el valor 15 correspondería con el valor mínimo a considerar en la interpolación, 26 el valor máximo, y data serían los valores de los puntos de la ontología según el formato { x: longitude, y: latitude, value: weight }.


Para obtener dichos valores, se puede generar una función que itere cada elemento de la ontología, devolviendo un listado con los valores de coordenadas y pesos de cada uno, pasando posteriormente dicho listado a la función de generación de los mapas de calor. En el caso de la ontología de ejemplo, la función utilizada es la siguiente:

Función que itera el JSON y extrae los datos necesarios
function getData(values) { /** Lista que contendrá las coordenadas y valores de cada punto */ let data = [] /** Se itera por cada entidad del JSON */ for (let feature of values.features) { /** Se definen los campos de longitud, latitud y peso */ let longitude = feature.geometry.coordinates[0] let latitude = feature.geometry.coordinates[1] let weight = feature.properties.value /** Se añade cada campo al listado */ data.push({ x: longitude, y: latitude, value: weight }) } /** Se devuelve el listado de puntos */ return data }

 

Entonces, la función para añadir los datos quedaría de la siguiente forma, entendiendo como jsonData el objeto que devuelve la función donde se encuentra el código:

Añadir el mapa de calor al visor
/** Se añade el mapa de calor al visor, considerando los datos de entrada como de longitud y latitud (WGS84) */ heatMapInstance.setWGS84Data(15, 26, getData(jsonData))

Hecho esto, visualizando el mapa debería verse el mapa de calor tal como:

 

Ver el mapa en grande

¿Quieres ver el mapa en mayor tamaño? Lo tenemos subido a la Plataforma. Échale un ojo y no te preocupes por el código fuente; al final de este tutorial lo podrás encontrar íntegro.

Configuración del mapa de calor

Las opciones de personalización del mapa de calor son las siguientes:

  • useEntitiesIfAvailable: indica si se deben usar entidades para la generación del mapa de calor (true) o si siempre se usa un proveedor de imágenes (false). Por defecto es true.

  • minCanvasSize: tamaño mínimo (en píxeles) para el canvas del mapa de calor. Por defecto es 700.

  • maxCanvasSize: tamaño máximo (en píxeles) para el canvas del mapa de calor. Por defecto es 2000.

  • radius: el tamaño de la interpolación de cada punto. Si el radio es insuficiente y no llega a tocar otro punto, el resultado será una serie de círculos concéntricos.

  • radiusFactor: factor de tamaño que se utiliza si no se indica un radio. El valor corresponde a la altura y anchura mayores divididas por este factor genera el valor del radio a utilizar. Por defecto es 60.

  • spacingFactor: espacio adicional alrededor de los bordes de la zona de análisis. El espaciado extra se calcula multiplicando el radio de los puntos por este valor. Por defecto es 1.5.

  • maxOpacity: valor de opacidad máxima que se utiliza. Por defecto es 0.8.

  • minOpacity: valor de opacidad mínimo que se utiliza. Por defecto es 0.1.

  • blur: valor de desenfoque a representar. Por defecto es 0.85.

  • gradient: colección de colores y límites que conforma el gradiente de colores del mapa. Por defecto es:

    • '.3': 'blue',

    • '.65': 'yellow',

    • '.8': 'orange',

    • '.95': 'red'

A menos que la situación lo requiera, es mejor interactuar únicamente con las propiedades de radio, opacidades máxima y mínima, desenfoque y gradiente, puesto que las otras no dan un resultado visual tan directo y su modificación puede llegar a generar aberraciones según los valores a representar en el mapa de calor (o directamente no notar ningún cambio aparente).

 

Respecto a la simbología del mapa de calor, esta se consigue modificando las opciones del gradiente. Cada rango de gradiente se encuentra definido por el valor de corte del gradiente (por debajo un color, por encima otro color; .65 por ejemplo significa que por debajo de ese valor tendrá al amarillo, y por encima de ese valor al naranja), y el valor del color a utilizar. Dicho color está basado en CSS, por lo que acepta tanto el valor nombre del color (blue) como el valor hexadecimal del color (#0000FF).

A continuación se muestra el mapa de calor con distintos valores de gradiente:





Valores de corte del gradiente

Puedes hacer tantos saltos de color como quieras; dos, tres, nueve, etc. Sí que debes de tener en cuenta de seguir la progresión de porcentajes desde 0 a 100 (o como funciona Cesium, de 0 a 1) → 0.1, 0.2, 0.45, 0.50, 0.65, 0.80, 0.90


Código del visor

A continuación se muestra el código utilizado en el visor de ejemplo.

Código del visor al completo
/** Definición del visor */ const viewer = new Cesium.Viewer('cesiumContainer', { /** Desactiva el widget de la pelota inferior izquierda */ animation: false, /** Botón de inicio */ homeButton: false, /** Widget del selector de mapas base */ baseLayerPicker: false, /** Botón de pantalla completa */ fullscreenButton: false, /** Cajetín del geocodificador */ geocoder: false, /** Ventana de información */ infoBox: false, /** Mapa base */ imageryProvider: Cesium.createOpenStreetMapImageryProvider({ url: 'https://a.tile.openstreetmap.org/' }), /** Botón de ayuda a la navegación */ navigationHelpButton: false, /** Botón de selección de modos 2D/2,5D/3D */ sceneModePicker: false, /** Widget de la barra temporal inferior */ timeline: false, /** Forzar modo de escena */ sceneMode: Cesium.SceneMode.SCENE3D, /** Recuadro verde que sale al seleccionar entidades */ selectionIndicator: false }) /** Se elimina el efecto HDR de la versión 1.52 */ viewer.scene.highDynamicRange = false /** Se elimina el dolor azul de fondo */ viewer.scene.globe.baseColor = Cesium.Color.WHITE /** API REST call */ jQuery.ajax({ /** URL al servicio Rest */ url: 'https://www.onesaitplatform.online/api-manager/server/api/v1/api_tuto_cesium_heatmap/getPointFeatures', /** Encabezado con la token de acceso al API */ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-OP-APIKey': 'token_de_usuario' }, /** Tipo de servicio del API */ type: 'GET', /** Contenido que ofrece la API; en este caso es un JSON */ contentType: 'application/json', /** En caso de que el token sea válido, se recupera la información de la * ontología y se sirve para trabajar con ella */ success: function(jsonData) { /** Se definen los límites del área de estudio */ let extent = getExtent(jsonData) /** Se definen las propiedades del mapa de calor */ let heatmapSetup = { radius: 170, maxOpacity: 0.6, minOpacity: 0.2, blur: 0.75 } /** Función que itera el JSON y extrae los datos de coordenadas y valor */ function getData(values) { /** Lista que contendrá las coordenadas y valores de cada punto */ let data = [] /** Se itera por cada entidad del JSON */ for (let feature of values.features) { /** Se definen los campos de longitud, latitud y peso */ let longitude = feature.geometry.coordinates[0] let latitude = feature.geometry.coordinates[1] let weight = feature.properties.value /** Se añade cada campo al listado */ data.push({ x: longitude, y: latitude, value: weight }) } /** Se devuelve el listado de puntos */ return data } function getExtent(values) { /** Se definen los arrays con los valores de longitud y latitud */ let longitudeArray = [] let latitudeArray = [] /** Se definen las variables con los valores extremos de cada tipo */ let highestLatitude let lowestLatitude let highestLongitude let lowestLongitude for (let feature of values.features) { let longitude = feature.geometry.coordinates[0] let latitude = feature.geometry.coordinates[1] longitudeArray.push(longitude) latitudeArray.push(latitude) } /** Se obtienen los valores extremos de longitud */ highestLongitude = Math.max(...longitudeArray) lowestLongitude = Math.min(...longitudeArray) /** Se obtienen los valores extremos de latitud */ highestLatitude = Math.max(...latitudeArray) lowestLatitude = Math.min(...latitudeArray) /** Se define la variable que tendrá la extensión de la zona de estudio */ let extent extent = { west: lowestLongitude - 0.005, south: lowestLatitude - 0.005, east: highestLongitude + 0.005, north: highestLatitude + 0.005 } return extent } /** Se genera la instancia del mapa de calor */ let heatMap = CesiumHeatmap.create(viewer, extent, heatmapSetup) /** Se añade el mapa de calor al visor */ heatMap.setWGS84Data(4, 26, getData(jsonData)) /** Se hace zoom al mapa de calor */ viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees( -15.4301002359, 28.1405417072, 6000.0 ), orientation: { heading: Cesium.Math.toRadians(0), pitch: Cesium.Math.toRadians(-90.0), roll: 0.0 } }) }, /** En caso de que el token no sea válido, se lanza el error */ error: function(data, errorThrown) { alert('Se ha producido un problema con la lectura de la ontología.') } })