tech explorers, welcome!

Categoría: Mapas (Página 1 de 2)

☮️ #WarTraces : la huella aérea de un mundo en guerra.

Todo empezó como un ejercicio de curiosidad, pensando si el ataque al puente de Kerch en Crimea se habría visto desde el espacio.

Para mi sorpresa, este será uno de los menos visibles de los ataques ocurridos durante la guerra de Ucrania.

Con cada noticia, comprobaba las imágenes satélite (accesibles públicamente) de la Agencia Espacial Europea y encontré evidencias de muchas de las informaciones. Las imágenes satélite son puramente objetivas. No hay manipulación o un discurso detrás.

Esto es totalmente opuesto a tomar un bando. Se trata de mostrar la realidad y la escala de la guerra. La auténtica y única verdad es que la violencia y la guerra tienen que ser condenadas en todas sus formas y prevenidas con cada mínimo esfuerzo que uno pueda hacer.

#WarTraces empieza en Ucrania, porque está cerca de los europeos, 24/7 en TV, y afectando a muchas y poderosas economías. Pero hay muchos otros conflictos activos en el mundo. Muchos otros que parecen olvidados. Muchos otros que no parecen tener importancia. Muchos otros que deben cesar igual que este.

Sígueme en 🐦 Twitter para saber cuando se actualiza este post!

🐦 @RoamingWorkshop

PAZ ☮ HERMANOS

TIP! Haz click en una imagen para ampliarla.

Guerra de Ucrania: 24 Feb 2022 - hoy

📖 Cronología de Wikipedia.

Guerra Civil Siria: 2011 - hoy

📖 Cronología de Wikipedia.

Conflicto Palestino-Israelí : 1948-hoy

📖 Cronología de Wikipedia.

El impacto de un incendio forestal en 3D con información geográfica en Cesium JS

Tras varios artículos sobre el uso de información satélite vamos a ver cómo unirlo (casi) todo en un ejemplo práctico e impactante. Y es que el tamaño de los últimos incendios ocurridos en España han llamado mucho mi atención y no era capaz de hacerme una idea de lo brutales que han sido (aunque nada que ver con otros sitios como Chile, Australia, o EEUU). Pero hagamos el ejercicio sin gastar demasiados GB de información geográfica.

Lo que quiero es mostrar la extensión del incendio ocurrido en Asturias en marzo, pero quiero también intentar mostrar el impacto retirando los árboles afectados por el fuego. ¡Vamos allá!

Descarga de datos

Usaré un Modelo Digital de Superficies (que incluye árboles y estructuras), una ortofoto tomada durante el incendio, y un Modelo Digital del Terreno (al que han eliminado los árboles y las estructuras) para reemplazarlo por las zonas afectadas por el incendio.

1. Modelos del terreno

Usaré los modelos del genial IGN, descargando los productos MDS5 y MDT5 de la zona.

http://centrodedescargas.cnig.es/CentroDescargas/index.jsp

2. Ortofotos

Para la imagen satélite, finalmente me decanto por Landsat ya que contaba con una imagen despejada durante los últimos días del incendio.

Usaré las imágenes tomadas el día 17 de febrero de 2023 (antes del incendio) y del 6 de abril de 2023 (ya en sus últimos días).

https://search.earthdata.nasa.gov

Procesar imágenes satélite

Usaremos el proceso i.group de GRASS en QGIS para agrupar las distintas bandas capturadas por el satélite en un único ráster RGB, tal y como vimos en este post:

https://theroamingworkshop.cloud/b/1725/procesar-imagenes-satelite-de-landsat-o-sentinel-2-en-qgis/

Tendremos que hacerlo para cada una de las regiones descargadas, cuatro en mi caso, que luego volveremos a unir usando el proceso Construir ráster virtual.

1. Imagen de color verdadero (TCI)

Combinamos las bandas 4, 3, 2.

2. Imagen de falso color

Combinamos las bandas 5, 4, 3.

3. Ajuste de la tonalidad

Para obtener un mejor resultado, puedes regular los valores mínimos y máximos que se consideran en cada banda que compone la imagen. Estos valores se encuentran en el Histograma de las propiedades de la capa.

Aquí te dejo los valores que yo he usado para obtener el resultado de arriba:

BandaTCI minTCI maxFC minFC max
1 Rojo-1001500-504000
2 Verde01500-1002000
3 Azul-10120001200

Extensión del incendio

Como ves, la imagen en falso color nos muestra claramente la extensión del incendio. Con ella, vamos a generar un polígono que delimite el alcance del incendio.

Primero consultaremos los valores de la banda 1 (rojo) que ofrece mayor contraste para la zona del incendio. Más o menos están en el rango 300-1300.

Usando el proceso Reclasificar por tabla, asignaremos el valor 1 a las celdas dentro del rango, y el valor 0 al resto.

Vectorizamos el resultado con el proceso Poligonizar y, contrastando con la imagen satélite, seleccionamos aquellos polígonos que correspondan con el incendio.

Usaremos la herramienta Disolver para unir todos los polígonos en un elemento, y Suavizar para redondear ligeramente los contornos.

Ahora obtenemos su inverso. Extraemos la extensión de la capa Landsat y, posteriormente, hacemos la Diferencia con el polígono del incendio.

Procesar terreno

1. Combinar los datos del terreno

Lo primero que haremos es combinar los distintos archivos que conforman los modelos en un archivo único (un único fichero MDS y un único fichero MDT).

Usamos el proceso GDAL - Miscelánea ráster - Construir ráster virtual

2. Extraer datos del terreno

Extraemos los datos que nos interesan de cada modelo:

  • Del MDS extraemos la superficie afectada por el incendio, de modo que quitaremos los árboles que hayan en él.
  • Con el MDT hacemos lo inverso: dejamos el terreno (sin árboles) de la zona del incendio, para sustituir los huecos generados en el otro modelo.

Usaremos el proceso Cortar ráster por capa de máscara empleando las capas generadas en el apartado anterior.

Finalmente unimos ambas capas ráster, para que rellenen una a la otra, usando Construir ráster virtual.

Dale vida con Cesium JS

Ya deberíamos tener un modelo de superficies sin árboles en la zona del incendio, pero vamos a intentar verlo de forma interactiva.

Ya mostré un ejemplo parecido, usando un Modelo Digital del Terreno personalizado, así como una imagen satélite reciente, del volcán Tajogaite de La Palma:

https://theroamingworkshop.cloud/b/1319/cesiumjs-el-visor-gratuito-de-mapas-en-3d-para-tu-web/

En este caso volveré a usar Cesium JS para poder interactuar fácilmente con el mapa (sigue el post anterior para ver cómo subir tus ficheros personalizados al visor Cesium JS).

Para esta ocasión he creado una pantalla dividida (usando dos instancias de CesiumJS) para poder comparar el antes y el después del incendio. Aquí tienes una vista previa:

https://theroamingworkshop.cloud/demos/cesiumJSmirror/

Espero que te guste! Aquí tienes el código completo y el enlace a github para que puedas descargarlo. Y recuerda, comparte tus dudas o comentarios en twitter!

🐦 @RoamingWorkshop

<html lang="en">
<head>
<meta charset="utf-8">
<title>Cesium JS mirror v1.0</title>
<script src="https://cesium.com/downloads/cesiumjs/releases/1.96/Build/Cesium/Cesium.js"></script>
<link href="https://cesium.com/downloads/cesiumjs/releases/1.96/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
</head>
<body style="margin:0;width:100vw;height:100vh;display:flex;flex-direction:row;font-family:Arial">
<div style="height:100%;width:50%;" id="cesiumContainer1">
	<span style="display:block;position:absolute;z-Index:1001;top:0;background-color: rgba(0, 0, 0, 0.5);color:darkorange;padding:13px">06/04/2023</span>
</div>
<div style="height:100%;width:50%;background-color:black;" id="cesiumContainer2">
	<span style="display:block;position:absolute;z-Index:1001;top:0;background-color: rgba(0, 0, 0, 0.5);color:darkorange;padding:13px">17/02/2023</span>
	<span style="display:block;position:absolute;z-Index:1001;bottom:10%;right:0;background-color: rgba(0, 0, 0, 0.5);color:white;padding:13px;font-size:14px;user-select:none;">
		<b><u>Cesium JS mirror v1.0</u></b><br>
		· Use the <b>left panel</b> to control the camera<br>
		· <b>Click+drag</b> to move the position<br>
		· <b>Control+drag</b> to rotate camera<br>
		· <b>Scroll</b> to zoom in/out<br>
		<span><a style="color:darkorange" href="https://theroamingworkshop.cloud" target="_blank">© The Roaming Workshop <span id="y"></span></a></span>
    </span>
</div>
<script>
	// INSERT ACCESS TOKEN
    // Your access token can be found at: https://cesium.com/ion/tokens.
    // Replace `your_access_token` with your Cesium ion access token.

    Cesium.Ion.defaultAccessToken = 'your_access_token';

	// Invoke LEFT view
    // Initialize the Cesium Viewer in the HTML element with the `cesiumContainer` ID.

    const viewerL = new Cesium.Viewer('cesiumContainer1', {
		terrainProvider: new Cesium.CesiumTerrainProvider({
			url: Cesium.IonResource.fromAssetId(1640615),//get your asset ID from "My Assets" menu
		}),	  
		baseLayerPicker: false,
		infoBox: false,
    });    

	// Add Landsat imagery
	const layerL = viewerL.imageryLayers.addImageryProvider(
	  new Cesium.IonImageryProvider({ assetId: 1640455 })//get your asset ID from "My Assets" menu
	);
	
	// Hide bottom widgets
	viewerL.timeline.container.style.visibility = "hidden";
	viewerL.animation.container.style.visibility = "hidden";

    // Fly the camera at the given longitude, latitude, and height.
    viewerL.camera.flyTo({
      destination : Cesium.Cartesian3.fromDegrees(-6.7200, 43.175, 6000),
      orientation : {
        heading : Cesium.Math.toRadians(15.0),
        pitch : Cesium.Math.toRadians(-20.0),
      }
    });
    
    // Invoke RIGHT view

    const viewerR = new Cesium.Viewer('cesiumContainer2', {
		terrainProvider: new Cesium.CesiumTerrainProvider({
			url: Cesium.IonResource.fromAssetId(1640502),//get your asset ID from "My Assets" menu
		}),	  
		baseLayerPicker: false,
		infoBox: false,
    });    

	// Add Landsat imagery
	const layerR = viewerR.imageryLayers.addImageryProvider(
	  new Cesium.IonImageryProvider({ assetId: 1640977 })
	);
	
	// Hide bottom widgets
	viewerR.timeline.container.style.visibility = "hidden";
	viewerR.animation.container.style.visibility = "hidden";

    // Fly the camera at the given longitude, latitude, and height.
    viewerR.camera.flyTo({
      destination : Cesium.Cartesian3.fromDegrees(-6.7200, 43.175, 6000),
      orientation : {
        heading : Cesium.Math.toRadians(15.0),
        pitch : Cesium.Math.toRadians(-20.0),
      }
    });
    
    // Invoke camera tracker
    //define a loop
    var camInterval=setInterval(function(){

	},200);
    clearInterval(camInterval);
    
    document.onmousedown=trackCam();
    document.ondragstart=trackCam();
    
    //define loop function (read properties from left camera and copy to right camera)
    function trackCam(){
		camInterval=setInterval(function(){
			viewerR.camera.setView({
				destination: Cesium.Cartesian3.fromElements(
					  viewerL.camera.position.x,
					  viewerL.camera.position.y,
					  viewerL.camera.position.z
					),
				orientation: {
					direction : new Cesium.Cartesian3(
						viewerL.camera.direction.x,
						viewerL.camera.direction.y,
						viewerL.camera.direction.z),
					up : new Cesium.Cartesian3(
						viewerL.camera.up.x,
						viewerL.camera.up.y,
						viewerL.camera.up.z)
				},
			});
		},50);
	};
	//stop loop listeners (release mouse or stop scroll)
	document.onmouseup=function(){
		clearInterval(camInterval);
	};
	document.ondragend=function(){
		clearInterval(camInterval);
	};
	
	//keep the copyright date updated
	var y=new Date(Date.now());
	document.getElementById("y").innerHTML=y.getFullYear();
  </script>
</div>
</html>

Procesa datos LIDAR en QGIS y crea tu propio Modelo Digital del Terreno

Hay veces que un Modelo Digital del Terreno (MDT) se queda corto o no está muy limpio. Si tienes acceso a datos LIDAR, puedes generar tú mismo un modelo del terreno y sacarle más partido obteniendo mayor detalle de las zonas que te interesen. Vamos a ver cómo.

1. Descarga de datos

Nube de puntos

Voy a usar los magníficos datos públicos del Instituto Geográfico Nacional (IGN, España) obtenidos mediante vuelos que utilizan técnicas de medición láser (LIDAR).

  1. Accede a los datos LIDAR del Centro de Descargas del IGN.

http://centrodedescargas.cnig.es/CentroDescargas/index.jsp

  1. Dibuja un polígono de la zona de interés.
  1. Descarga los archivos PNOA-XXXX...XXXX-RGB.LAZ. RGB emplea color verdadero; ICR, infrarrojo. Pero ambos son válidos.

TIP! Descarga todos los ficheros a la vez utilizando el fichero el applet del IGN. Es un fichero .jnlp que requiere JAVA instalado en Windows o IcedTea en Linux (sudo apt-get install icedtea-netx)

2. Procesado de nube de puntos LIDAR en QGIS

Visualización directa

Desde las últimas versiones (p.e.: 3.28 LTR Firenze), QGIS permite visualizar directamente los ficheros de nube de puntos.

En el menú Capa -> Añadir capa... -> Añadir capa de nube de puntos...

Se mostrarán los datos que hemos descargado en color verdadero, que podemos clasificar en las Propiedades de Simbología, eligiendo la representación de Clasificación por tipo de datos:

Vista 3D

Otra función que trae ahora QGIS por defecto es la visualización de la información en 3D.

Vamos a configurar las propiedades 3D de la capa LIDAR para triangular las superficies y obtener un mejor resultado.

Ahora creamos una nueva vista en el menú Ver -> Vistas del Mapa 3D -> Nueva vista del mapa 3D. Con SHIFT+Arrastrar rotaremos la vista en perspectiva.

Complemento de LAStools

Para utilizar la información fácilmente usaremos las herramientas del plugin LAStools, que instalaremos de la siguiente manera:

TIP! En Linux es recomendable tener instalado Wine para trabajar con los ficheros .exe o será necesario compilar los binarios.

  1. Accede a la web de LAStools y navega a la parte inferior:

https://lastools.github.io/

  1. La herramienta completa es de pago, pero puedes acceder a la descarga pública para utilizar las funciones básicas que necesitamos.
  1. Descomprime el fichero .zip en una carpeta sencilla (sin espacios ni caracteres especiales)
  1. Ahora abre QGIS, busca el complemento LAStools e instálalo.
  1. Por último, configura la ruta de instalación de LAStools (si es distinta de su valor por defecto C:/ ). La configuración mostrada abajo sirve para Linux con Wine instalado (en mi caso uso PlayOnLinux).

Extraer tipos de datos LIDAR

Con LAStools podemos extraer información de los distintos tipos de datos que componen la nube de puntos. Por ejemplo, vamos a extraer solo los datos clasificados como Suelo (que se corresponden con el valor 2).

Con el proceso las2las_filter podremos crear una nueva nube de puntos filtrada:

  • Selecciona el fichero a filtrar.
  • En filter, elige la opción keep_class 2 para conservar solo el tipo de datos 2 (suelo)
  • Deja lo demás por defecto, e introducir 0 en los campos que requieren un valor value (de lo contrario devolverá un error).
  • Por último, guarda el fichero con formato .laz en una ubicación conocida para encontrarlo fácilmente.

Al finalizar solo tendrás que cargar el fichero generado para ver la nueva nube de puntos con valores exclusivamente del terreno (edificios y vegetación eliminados).

Conversión de LIDAR a vectorial

Ahora usaremos el proceso las2shp para transformar la nube de puntos a formato vectorial y poder operar normalmente con otras herramientas de GIS:

  • Elige el fichero de nube de puntos filtrado anteriormente.
  • Especifica 1 punto por registro para extraer todos los puntos de la nube.
  • Guarda el fichero con formato .shp en una ubicación conocida para encontrarlo fácilmente.

Y esta será tu nube de puntos filtrada en el formato clásico vectorial.

Como verás, la tabla de atributos no cuenta con ningún campo específico. Yo voy a crear un campo ELEV para guardar las coordenadas Z (o cota) y utilizarlas para generar un Modelo Digital del Terreno a continuación.

3. Creación del Modelo Digital del Terreno

Raster a partir de capa de puntos vectorial

Gracias a la integración de GRASS GIS, disponemos de potentes herramientas de procesado vectorial y ráster. Vamos a usar v.surf.idw para generar una malla regular a partir de la interpolación de los datos de una capa de puntos (en este caso se ponderan los valores obtenidos mediante el inverso de la distancia, pero también hay algoritmos para emplear splines).

  • Seleccionamos la capa vectorial de puntos.
  • Elegimos el número de puntos para emplear en la interpolación (en este caso los puntos son bastante densos así que elijo 50). Cuantos más elijas, más suavizado será el resultado, pero perderás el detalle de la densidad de la información.
  • Dejamos la potencia del inverso de la distancia en 2, para emplear el "inverso de la distancia al cuadrado".
  • Seleccionamos el campo de datos que usará la interpolación (ELEV).
  • Definimos el tamaño de celda de la malla. Elijo 2 para poder comparar el resultado con el producto MDT 2 metros del IGN.

4. Resultado

Vamos a quitar zoom para ver cómo ha quedado todo:

Y ahora veamos un poco más el detalle.

Aplicamos la misma rampa de color al MDT que hemos generado y al producto descargado del IGN para comparar el resultado obtenido. En general es muy bueno, con algunas diferencias en zonas arboladas, siendo más razonable el resultado de nuestro procesado.

¡Y eso es todo! Cualquier duda o comentario lo puedes dejar en Twitter!

🐦 @RoamingWorkshop

Procesar imágenes satélite de Landsat o Sentinel 2 en QGIS

Si conoces mis artículos de #WarTraces, verás que utilizo imágenes satélite de la Agencia Espacial Europea. Hoy os cuento cómo descargar y manipular estas imágenes de la misión Sentinel 2, así como de Landsat, ya que el tratamiento es similar para ambos.

Lo haremos con QGIS, para lo cual deberás descargar e instalar este programa con el módulo de GRASS GIS incluido.

Registro en plataformas.

Necesitaremos crearnos una cuenta para poder acceder a ambas fuentes de datos, rellenando un formulario y confirmando la cuenta por correo electrónico.

Copernicus Open Access Hub

https://scihub.copernicus.eu/dhus/#/self-registration

NASA Earthdata Search

https://urs.earthdata.nasa.gov/users/new

Descarga de datos.

Diferenciemos según la plataforma utilizada.

Copernicus Open Access Hub

Accedemos a su visor de datos y nos logueamos con las credenciales creadas.

https://scihub.copernicus.eu/dhus/

Ahora, dibujaremos el polígono delimitador de la zona de interés. Cada punto del rectángulo se marca haciendo click derecho, y cerramos el polígono con doble click derecho.

A continuación, pulsamos el botón de filtro de la barra de búsqueda y seleccionamos la casilla de la misión Sentinel-2 (también podremos delimitar las fechas de la consulta, pero por defecto se ordenará de más reciente a más antiguo).

Por último, aparecerán los productos disponibles para nuestra consulta, los cuales podremos previsualizar o descargar en un fichero .zip comprimido.

NASA Earthdata Search

Igual que antes, deberemos loguearnos primero en su visor de datos.

https://search.earthdata.nasa.gov/search

Podremos definir también una zona de interés mediante las herramientas de geometría de la barra lateral.

Filtramos los productos, por ejemplo seleccionando "Imagery", y seleccionamos HLS Landsat en el catálogo.

En este caso, deberemos seleccionar las bandas que queramos descargar y que veremos a continuación.

Imagen TCI (true colour o color verdadero)

Simplificando (mucho), los satélites capturan imágenes en distintos rangos de longitudes de onda, denominados bandas.

Además, las imágenes o fotografías digitales que usamos para representar la realidad como las ve el ojo humano suelen estar definidas por tres capas de datos: una para el color rojo (Red), otra para el color verde (Green), y otra para el azul (Blue), definiendo en cada una de estas capas un valor para cada pixel con la cantidad de ese color. La combinación de todos los píxeles es lo que simula una imagen similar a la realidad.

TIP! Haz la prueba y haz mucho zoom a cualquier imagen y verás el mosaico de píxeles que la forma.

Utilizaremos las bandas capturadas por los satélites dentro de la longitud de onda visible para generar una imagen tal y como la veríamos desde el espacio.

Sentinel 2

En el caso de Sentinel 2, podemos mostrar directamente una imagen de color verdadero descargando el producto L1C y empleando la capa _TCI.jp2 ubicada en la ruta /GRANULE/DATA_CODE_NAME/IMG_DATA/

La imagen TCI se verá de la siguiente forma:

Deir ez Zor, Siria, 22/10/2017. TCI. Fuente: Copernicus Sentinel 2.

Landsat

TIP! https://gisgeography.com/landsat-8-bands-combinations/

Para Landsat, deberemos combinar las diferentes bandas capturadas individualmente por el satélite dentro de la longitud de onda visible. Estas son:

REDB04
GREENB03
BLUEB02

Acudimos a QGIS, y usamos la función i.group del módulo GRASS que combinará las capas seleccionadas en una nueva capa multibanda RGB.

Para facilitar la tarea, yo renombro las capas para ordenarlas de la siguiente manera, asegurándote que el script las lee correctamente:

Quedando así:

En este caso no se parece nada a la imagen anterior o a la realidad, así que ajustamos las propiedades para extraer más color de los datos:

  • En primer lugar nos aseguramos de leer toda la información de las bandas usando los valores mínimo y máximo reales, en lugar de los estimados.
  • Solo con esto mejora bastante la imagen, pero le falta algo de brillo y de color.
  • Ajustamos ahora los parámetros de renderizado de color:

Y ya queda algo mucho más realista:

Deir ez Zor, Siria, 21/10/2017. TCI. Fuente: Landsat.

Capa SWIR (short wave infra-red)

Utilizando las bandas de longitud de onda infra-roja, podemos ver más allá de lo que ve el ojo humano, o incluso a través de ciertos objetos, como es el caso de ciertas nubes o el humo.

Durante los incendios del verano, vi cómo Copernicus utilizaba estas imágenes para delimitar la extensión del fuego, y pensé que servirían para localizar el impacto de las bombas que oculta el humo que desprenden. Y así fue cómo se me ocurrió utilizarlo para #WarTraces.

Sentinel 2

Usamos el procedimiento anterior, combinando para este caso las siguientes bandas:

REDB12
GREENB8A
BLUEB04
Deir ez Zor, Siria, 22/10/2017. SWIR. Fuente: Copernicus Sentinel 2.

Landsat

Realizamos una nueva combinación, en este caso usando las bandas siguientes:

REDB07
GREENB06
BLUEB04
Deir ez Zor, Siria, 21/10/2017. SWIR. Fuente: Landsat.

Conclusión

Como nota final, es importante destacar el tamaño de pixel, ya que afectará al detalle de la imagen (resolución):

  • Sentinel 2: 10 metros.
  • Landsat: 30 metros.

Por otro lado, Landsat cuenta con imágenes desde 2013, mientras que Sentinel solo dispone de imágenes desde 2015.

Además, puedes previsualizar todas estas capas y combinaciones con visores web como EO Browser de Sentinel Hub.

Bombardeos al Aeropuerto Internacional Antonov en Kyiv, Ucrania, el 26/2/2022, usando EO Browser.

Si te atascas o quieres comentar, te espero en 🐦 Twitter!

🐦 @RoamingWorkshop

BlenderGIS: modelado 3D de información geográfica en Blender

Blender es (para mí), el programa de modelado 3D gratuito por excelencia. Tiene una amplísima comunidad, muchísima documentación, tutoriales y, sobre todo, continuas actualizaciones y mejoras.

Uno de sus plugins más útiles es BlenderGIS, con el que podremos volcar datos geográficos, georreferenciados o no, para poder modelar con ellos.

Vamos a ver un ejemplo de uso para un modelo de elevaciones.

Instalación

Lo primero que debemos hacer es descargar e instalar Blender desde su fuente oficial (su página web) o desde la tienda de aplicaciones de nuestro sistema operativo:

https://www.blender.org/download/

Ahora descargaremos BlenderGIS desde el github de su autor en su formato .zip comprimido:

https://github.com/domlysz/BlenderGIS

A continuación ejecutamos Blender y abrimos los ajustes de Add-ons (Edit > Preferences > Add-ons).

Pulsamos en "Install..." y seleccionamos el fichero .zip de BlenderGIS.

Ahora podremos buscarlo y activarlo.

Verás que ahora aparece el menú "GIS" en la barra superior de Blender.

Descargar información geográfica

En este ejemplo yo voy a usar un Modelo Digital del Terreno en formato ASCII (.asc), ya que es uno de los formatos de trabajo de BlenderGIS y también el formato estándar de descarga.

Si la información la descargas en otro formato como .tiff o .xyz, lo podrás convertir usando algún programa de escritorio como QGIS o ArcGIS.

MDT

En mi caso, usaré el MDT200 del IGN español, un modelo con paso de malla (o tamaño de celdas) de 200 metros, y es que quiero representar una zona bastante amplia que incluye la provincia de Álava.

https://centrodedescargas.cnig.es/CentroDescargas/index.jsp

Ortofoto

Además, podemos usar una ortofoto como textura del terreno. Para ello me ayudaré de QGIS, y cargaré el servicio WMS del PNOA (también del IGN) para recortar la imagen satélite al gusto.

Cargada la ortofoto, la exportaremos como imagen renderizada para la extensión de nuestro MDT, y con tamaño de celda de 20 metros (la ortofoto admite hasta unos 20cm de tamaño de celda, pero el archivo sería ya excesivamente grande, siendo la imagen de 20 metros de 140MB).

TIP! Una forma de optimizar el detalle es generar "teselas", o una cuadrícula de menor tamaño, pero mayor resolución.

Modelado en Blender

Así estaría todo listo para trabajar en Blender.

Usando el menú "GIS", importamos la capa como malla ASCII. Verás que en seguida aparecerá el modelo en pantalla.

TIP! Este modelo está centrado en el origen de coordenadas, pero se podría georeferenciar estableciendo su CRS en las propiedades de "Georeference".

Ahora añadimos la textura satélite:

  1. Creamos un nuevo material.
  1. Creamos una nueva textura y cargamos la imagen satélite.
  1. Ahora nos movemos a la pestaña de UV Editing:
    • Selecciona la capa de terreno en la ventana derecha, entra en Edit Mode, y "Seleccionar todos" los polígonos (Ctrl+A). Deberías verlo naranja como abajo y asegúrate de estar en la vista "Supertior" (pulsa el número 7).
    • Despliega las herramientas "UV" del menú superior y proyecta la capa del terreno con "Project from View (bounds)". Esto hará que se ajuste a la extensión de la imagen.
  1. Elegimos la imagen de la textura para aplicarle la proyección y vemos que se ajustan las celdas del modelo a la imagen (haz un poco de zoom para comprobarlo).
  1. Por último, vamos a la pestaña de Shading y añadimos el elemento "Image Texture" seleccionando la imagen de la textura y conectamos el Vector al UV y el Color al Shader (copiar la imagen).

Ahora, si volvemos a la ventana de Layout, nuestro modelo mostrará la imagen satélite perfectamente ajustada.

Y ya estaría listo, con esto puedes ahora editar y exportar tu modelo, por ejemplo, para imprimirlo en 3D o usarlo en Unity3D.

¿Dudas o comentarios? Pásate por 🐦 Twitter!

🐦 @RoamingWorkshop

TurfJS: herramientas geoespaciales interactivas para tus mapas web

Si has usado software GIS de escritorio, te sorprenderá saber que muchas de esas herramientas geoespaciales también están disponibles para tus mapas web mediante una librería javascript gratuita y de código abierto.

Hoy te enseño TurfJS. Pulsa el botón 🌤 del mapa incrustado y verás una de sus muchas posibilidades: la interpolación por inverso de la distancia al cuadrado o IDW.

Instalación

TurfJS cuenta con una práctica web que nos explica todas las funciones disponibles, así como la configuración inicial:

https://turfjs.org/getting-started

Para usar Turf sólo necesitamos enlazar la librería a nuestro mapa usando el enlace CDN que dan en su web:

<script src='https://unpkg.com/@turf/turf@6/turf.min.js'></script>

Mapa base

Puedes crear un mapa base, por ejemplo, con Leaflet. Además, TurfJS utiliza sintaxis JSON, por lo que es altamente compatible con las funciones geoJSON de Leaflet.

TIP! Leaflet ha sido recientemente actualizado a la versión 1.9.1. Este post todavía no está actualizado y utiliza la versión 1.7.1.

Yo voy a reciclar este ejemplo de uno de mis primeros posts con algunos cambios:

  • Añado el enlace a la librería Turf en el <head>.
  • Defino los marcadores en una matriz o array de datos, similar a como se obtendrían de una fuente de datos en formato JSON:

https://www.w3schools.com/js/js_arrays.asp

var markers=[
{"name":"Estacion1",
"lat":25.77,
"lon":-80.13,
"dato":26.4},
{"name":"Estacion2",
"lat":25.795,
"lon":-80.21,
"dato":29.8},
{"name":"Estacion3",
"lat":25.70,
"lon":-80.275,
"dato":31.35}
];

TIP! Los valores numéricos NO irán "entrecomillados", o de lo contrario Turf los reconocerá como String (en Leaflet no ocurre).

  • Añado los marcadores al mapa con un bucle, usando la función forEach(), muy habitual para estos casos:

https://www.w3schools.com/jsref/jsref_foreach.asp

markers.forEach(function(m){
    var marker=L.marker([m.lat, m.lon]).addTo(map);
    marker.bindTooltip("<b>"+m.name+"</b><br>"+m.dato).openTooltip();
});

//ASÍ AÑADÍAMOS UN MARCADOR ÚNICO A LEAFLET
//var marker = L.marker([25.77, -80.13]).addTo(map);
//marker.bindTooltip("<b>Estacion1</b><br>m.dato").openTooltip();

Quedando así:

y siendo éste su código:

<head>
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css"
  integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
  crossorigin=""/>
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"
  integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
  crossorigin=""></script>
<script src='https://unpkg.com/@turf/turf@6/turf.min.js'></script>
</head>

<style>
#map{border-radius: 30px;}
</style>

<body>
<div id="map" style="height:500px;"></div>
</body>

<script>
var map = L.map('map').setView([25.75, -80.2], 12);
L.tileLayer("http://a.tile.openstreetmap.org/{z}/{x}/{y}.png", {/*no properties*/
}).addTo(map);

var markers=[
{"name":"Estacion1",
"lat":25.77,
"lon":-80.13,
"dato":26.4},
{"name":"Estacion2",
"lat":25.795,
"lon":-80.21,
"dato":29.8},
{"name":"Estacion3",
"lat":25.70,
"lon":-80.275,
"dato":31.35}
];

markers.forEach(function(m){
    var marker=L.marker([m.lat, m.lon]).addTo(map);
    marker.bindTooltip("<b>"+m.name+"</b><br>"+m.dato).openTooltip();
});
</script>

Interpolación IDW con TurfJS

En la forma anterior, los datos se pueden usar muy fácilmente con TurfJS.

Una de las herramientas más interesantes que facilita Turf es la interpolación de los datos en una malla regular.

https://turfjs.org/docs/#interpolate

Esto lo hace empleando uno de los métodos más habituales: el inverso de la distancia al cuadrado (IDW) o inverse distance weighting para una potencia de 2.

IDW de Wikipedia

Seguimos el ejemplo que nos da la web de TurfJS:

  1. Convertir los datos a una capa de puntos de Turf, una FeatureCollection:

https://turfjs.org/docs/#featureCollection

//vector de puntos vacío
var points=[];

//bucle para rellenar el vector de puntos con los datos
markers.forEach(function(m){
    points.push(turf.point([m.lon, m.lat],{name: m.name, dato: m.dato}));
});

//convertir a featureCollection de Turf
points=turf.featureCollection(points);

TIP! Fíjate que Turf recibe las coordenadas en longitud-latitud, en lugar del habitual latitud-longitud!

  1. Definir las opciones de la interpolación, entre las que podemos elegir las siguientes:
    • gridType: o tipo de malla
      • points (puntos)
      • square (cuadrados)
      • hex (hexágonos)
      • triangle (triángulos)
    • property: propiedad (o campo) del objeto de puntos que contiene los valores a interpolar.
    • units: unidades espaciales usadas en la interpolación
      • miles: millas
      • kilometers: kilómetros
      • radians: radianes
      • degrees: grados
    • weight: peso o potencia a la que se eleva la distancia, habitualmente 2, aunque valores más altos darán un resultado más suavizado.
//opciones de interpolación: malla rectangular de la variable 'dato' en kilómetros usando potencia de 2

var options = {gridType: 'square', property: 'dato', units: 'kilometers', weight: 2};
  1. Por último, generar la malla interpolada y añadirla al mapa, siendo el valor numérico el tamaño de las celdas de la malla interpolada (0.5 Km):
//crear malla de Turf
var malla=turf.interpolate(points, 0.5, options);

//añadir a Leaflet
L.geoJSON(malla).addTo(map);

Siendo este el resultado (nada concluyente):

Dar formato a la malla y mostrar valores

Aunque no lo parezca, la interpolación está hecha, aunque no se está mostrando correctamente.

Voy a añadir una escala de color que represente los datos, y también a mostrar los valores al pasar por encima de las celdas. Para ello, modificamos las propiedades del objeto geoJSON de Leaflet:

  • style: función para modificar el estilo de los objetos, evaluando su valor.
  • onEachFeature: función que se añade a cada objeto para interactuar con él.

https://leafletjs.com/reference.html#geojson

https://leafletjs.com/examples/choropleth/

L.geoJSON(malla,{
  style:function(feature){
  var val=feature.properties.dato;
  if(val>30){
    return {fillColor: "orangered", fillOpacity: 0.5, weight:0};
  }else if(val>28 && val < 30){
    return {fillColor: "yellowgreen", fillOpacity: 0.5, weight:0};
  }else if(val>26 && val < 28){
    return {fillColor: "darkgreen", fillOpacity: 0.5, weight:0};
  }
},
onEachFeature:function(feature,layer){
  layer.on({
  mouseover:function(e){
    val=e.target.feature.properties.dato.toFixed(2);
    e.target.bindTooltip(val).openTooltip();
  }
  });
}
}).addTo(map);

Quedando así:

Y aquí el código completo:

<head>
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css"
  integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
  crossorigin=""/>
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"
  integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
  crossorigin=""></script>
<script src='https://unpkg.com/@turf/turf@6/turf.min.js'></script>
</head>

<style>
#map{border-radius: 30px;}
</style>

<body>
<div id="map" style="height:500px;"></div>
</body>

<script>
var map = L.map('map').setView([25.75, -80.2], 12);
L.tileLayer("http://a.tile.openstreetmap.org/{z}/{x}/{y}.png", {/*no properties*/
}).addTo(map);

var markers=[
{"name":"Estacion1",
"lat":25.77,
"lon":-80.13,
"dato":26.4},
{"name":"Estacion2",
"lat":25.795,
"lon":-80.21,
"dato":29.8},
{"name":"Estacion3",
"lat":25.70,
"lon":-80.275,
"dato":31.35}
];

markers.forEach(function(m){
    var marker=L.marker([m.lat, m.lon]).addTo(map3);
    marker.bindTooltip("<b>"+m.name+"</b><br>"+m.dato).openTooltip();
});

//Datos para interpolación de Turf
//vector de puntos vacío
var points=[];
//bucle para rellenar el vector de puntos con los datos
markers.forEach(function(m){
points.push(turf.point([m.lon, m.lat],{name: m.name, dato: m.dato}));
});
//convertir a featureCollection de Turf

points=turf.featureCollection(points);

//opciones de interpolación
var options = {gridType:"square",property:"dato",units:"kilometers",weight:2};

//crear malla de Turf
var malla=turf.interpolate(points, 0.5, options);

//añadir a Leaflet
malla=L.geoJSON(malla,{
  style:function(feature){
  var val=feature.properties.dato;
  if(val>30){
    return {fillColor: "orangered", fillOpacity: 0.5, weight:0};
  }else if(val>28 && val < 30){
    return {fillColor: "yellowgreen", fillOpacity: 0.5, weight:0};
  }else if(val>26 && val < 28){
    return {fillColor: "darkgreen", fillOpacity: 0.5, weight:0};
  }
},
onEachFeature:function(feature,layer){
  layer.on({
  mouseover:function(e){
    val=e.target.feature.properties.dato.toFixed(2);
    e.target.bindTooltip(val).openTooltip();
  }
  });
}
}).addTo(map);

</script>

Aplicaciones

Como ejemplo aplicado a datos reales, he modificado la librería leafMET para incorporar la interpolación y añadido isobandas.

Es el mapa que has visto al inicio y que puedes abrir en pantalla completa:

https://theroamingworkshop.cloud/leafMET/leafMET+Turf.html

Además, el código está disponible en github como variante de leafMET:

https://github.com/TheRoam/leafMET/tree/leafMET+Turf

Y eso es todo! Espero que te sea útil!

Cualquier duda o comentario te espero en 🐦 Twitter!

🐦 @RoamingWorkshop

Usando la API de datos abiertos de AEMET OpenData

En este post explicaba directamente cómo añadir las estaciones de AEMET con ciertos datos, usando mi plugin leafMET para Leaflet (disponible en github):

https://github.com/TheRoam/leafMET

Vamos a detenernos ahora en el detalle de cómo funciona la API y cómo acceder a todos los datos disponibles.

Acceso a la API

Para usar la API necesitamos una clave de acceso, o API key. Accedemos a la página de AEMET OpenData y hacemos click en el botón "Solicitar" debajo de Obtención de API key.

A continuación se nos pedirá una dirección de correo electrónico para enviarnos dicha clave.

Nos enviarán un primer correo de confirmación, y a continuación recibiremos un segundo correo con la clave de acceso. La copiamos y seguimos.

Documentación y ejemplos

Volvemos a la página de AEMET OpenData y entramos al Acceso Desarrolladores.

Nos interesa la documentación dinámica, que muestra todas las peticiones de datos disponibles y que podemos probar directamente desde allí.

Podemos pulsar sobre cada tema y se mostrará la sintaxis a usar para cada petición de datos.

Vamos a desplegar observación-convencional y, dentro, /api/observacion/convencional/todas.

Veremos un icono de aviso rojo a la derecha, donde podemos introducir la API key y probar la petición.

Ahora pulsamos el botón Try it out! y nos dará:

  • un ejemplo de petición curl
  • la URL de la petición
  • el cuerpo, código y encabezados de la respuesta

Si abrimos la dirección URL que nos ofrece el campo "datos" veremos el listado completo de datos de todas las estaciones. A mi me sale todo este chorizo de JSON:

Cargando...

Los campos más interesantes son los siguientes:

  • idema: ID o código de la estación.
  • lat y lon: coordenadas de latitud y longitud.
  • alt: altitud de la estación.
  • fint: fecha del intervalo (horario) de los datos.
  • prec: precipitación registrada en ese periodo.
  • vv: velocidad del viento en la estación.
  • ta: temperatura ambiente en la estación

Petición de datos para web mediante javascript

Estructura HTML

Voy a desarrollar un ejemplo incrustado para acceder a los datos y hacer uso de ellos. Simplemente creo un documento .html con un botón de solicitud y una línea de texto y una función de solicitud:

Pulsa el botón para solicitar los datos

<html>
<body>
<button id="solicitud" onclick="solicitar()">Solicitar</button>
<p id="texto">Pulsa el botón para solicitar los datos</p>
</body>
<script>
function solicitar(){
document.getElementById("texto").innerHTML="Todavía no he programado eso..";
}
</script>
</html>

TIP! Copia y pega este código en un documento de texto con extensión .html para abrirlo en tu navegador.

Solicitud HTTP

Realizamos la solicitud de datos con javascript usando el objeto XMLHttpRequest. En w3schools nos ponen un ejemplo muy simple:

https://www.w3schools.com/js/js_ajax_http.asp

Como vimos usando la documentación dinámica, después de hacer la solicitud, recibimos un enlace para acceder a los datos, lo cual es otra solicitud, así que debemos anidar dos solicitudes así:

<script>
function solicitar(){
// Definir texto de carga
document.getElementById("texto").innerHTML="Cargando datos...";
// Definir la API Key
var AK=tUApiKey;
// Definir URLs de solicitud
var URL1="https://opendata.aemet.es/opendata/api/observacion/convencional/todas";
var URL2="";

// Crear objetos XMLHttpRequest
const xhttp1 = new XMLHttpRequest();
const xhttp2 = new XMLHttpRequest();

// Definir función de respuesta a la primera solicitud:
// Queremos acceder a la URL del campo "datos"
xhttp1.onload = function() {
  // 1º Pasamos la respuesta a JSON:
  URL2=JSON.parse(this.responseText);
  // 2º Obtenemos el campo "datos":
  URL2=URL2.datos;  
  // 3º Hacemos la nueva petición:
  xhttp2.open("GET", URL2);
  xhttp2.send();
}

// Definir función de respuesta a la segunda solicitud:
// Modificaremos la línea de texto de la página con información descargada
xhttp2.onload = function() {
  // 1º Pasamos la respuesta a JSON (se trabaja mejor así):
  var datos=JSON.parse(this.responseText);

  // 2º Obtenemos la longitud del objeto JSON
  // Esto equivale a registros horarios individuales por estaciones
  var registros=datos.length;

  // 3º Obtenemos la fecha del primer registro.
  // Le daremos un formato más legible ya que está en ISO
  var fechaini=datos[0].fint;
  fechaini=new Date(fechaini+"Z");
  fechaini=fechaini.toLocaleDateString()+" "+fechaini.toLocaleTimeString();

  // 4º Obtenemos la fecha del último registro
  // Le damos el mismo formato
  var fechafin=datos[(datos.length-1)].fint;
  fechafin=new Date(fechaini+"Z");
  fechafin=fechafin.toLocaleDateString()+" "+fechafin.toLocaleTimeString();

  // 5º Unimos la información en la línea de texto
  document.getElementById("texto").innerHTML="Se han descargado <b>"+registros+"</b> registros desde el <b>"+fechaini+"</b> hasta el <b>"+fechafin+"</b>. <a href='"+URL2+"' target='_blank'>Ver todos los datos.</a>";
}

// Enviar la solicitud
xhttp1.open("GET", URL1+"/?api_key="+AK);
xhttp1.send();

}
</script>

Veamos el resultado a continuación:

Pulsa el botón para solicitar los datos

Y ya está listo! De esta forma puedes obtener las coordenadas y los valores de las distintas variables meteorológicas horarias de AEMET para usarlas en una tabla o en un mapa web como hemos visto para Leaflet.

Como siempre, deja tus dudas o comentarios en Twitter! 🐦

CesiumJS: el visor gratuito de mapas en 3D para tu web

El pasado julio, la revista Nature publicaba un Modelo Digital de Superficies en alta definición de la nueva orografía creada por el volcán Tajogaite de La Palma durante su erupción en el año 2021. Un trabajo espectacular del Istituto Nazionale di Geofisica e Vulcanologia junto con el Instituto Vulcanológico de Canarias.

Lo habitual es ver y trabajar con estos ficheros en algún programa de SIG como QGIS o ArcGIS, pero ¿cómo lo acercamos al resto de usuarios ?¿No sería mejor verlo directamente en 3D y visitarlo virtualmente?

Aquí es donde me topé con CesiumJS: un visor de mapas en 3D al estilo del famoso [G] Earth, pero de código abierto, gratuito y altamente personalizable para hacer tus propios proyectos.

Demo del visor CesiumJS con la nueva cartografía del volcán Tajogaite con imagen satelital de IDE Canarias. Clicka en el mapa para interactuar con él, o ábrelo en su versión completa.

Crear cuenta de Cesium ion

Para utilizar CesiumJS es necesario registrar una cuenta para crear un código de acceso o Access Token.

Además, registrándonos podremos subir nuestros propios ficheros y personalizar las vistas 3D que crearemos. Verás que en mi caso he subido el Modelo Digital de Superficies y la imagen satélite que usa el visor. Dejo aquí los enlaces por si quieres usar los mismos:

TIP! Puedes usar el servicio WMS en tu visor GIS para obtener una ortofoto completa, o descargar la que uso yo para este ejemplo (270 MB con resolución de 1m)

Incorporar CesiumJS a tu web.

Para usar CesiumJS configuramos un fichero .html como ya viene siendo habitual.

En este caso voy a copiar directamente uno de sus ejemplos ya que apenas tiene unas 25 líneas:

Como verás, las partes fundamentales son las siguientes:

  • enlaces a las librerías de CesiumJS en el <head>
  • un elemento <div> con id "cesiumContainer" dentro del <body>
  • el código de acceso dentro de la variable Cesium.Ion.defaultAccessToken. Introduce aquí la tuya.
  • una variable de visor llamada "viewer"
  • la configuración inicial del visor, especificando las coordenadas iniciales, y los ángulos de inclinación X y Z.
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <!-- Include the CesiumJS JavaScript and CSS files -->
  <script src="https://cesium.com/downloads/cesiumjs/releases/1.97/Build/Cesium/Cesium.js"></script>
  <link href="https://cesium.com/downloads/cesiumjs/releases/1.97/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
</head>
<body>
  <div id="cesiumContainer"></div>
  <script>
    // Your access token can be found at: https://cesium.com/ion/tokens.
    // This is the default access token from your ion account
    Cesium.Ion.defaultAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI1MTYwNGM0Yi1iYzlkLTRkMTUtOGQyOS04YjQxNmUwZDQ0YjkiLCJpZCI6MTA0Mjc3LCJpYXQiOjE2NjAxMDk4MzR9.syNWeKPLWA2eMrEh4K9hvkyp1oGdbLMaL0Ozk1kaksk';
    // Initialize the Cesium Viewer in the HTML element with the `cesiumContainer` ID.
    const viewer = new Cesium.Viewer('cesiumContainer', {
      terrainProvider: Cesium.createWorldTerrain()
    });    
    // Add Cesium OSM Buildings, a global 3D buildings layer.
    const buildingTileset = viewer.scene.primitives.add(Cesium.createOsmBuildings());   
    // Fly the camera to San Francisco at the given longitude, latitude, and height.
    viewer.camera.flyTo({
      destination : Cesium.Cartesian3.fromDegrees(-122.4175, 37.655, 400),
      orientation : {
        heading : Cesium.Math.toRadians(0.0),
        pitch : Cesium.Math.toRadians(-15.0),
      }
    });
  </script>
 </div>
</body>
</html>

TIP! También puedes descargar las librerías de CesiumJS y usarlas de forma local o en tu servidor.

Añadir un terreno personalizado

Seguimos los pasos de la página de Cesium:

  • En tu cuenta de Cesium ion, añadimos un nuevo terreno en Add data.
  • Seleccionamos el fichero y elegimos el tipo de datos Raster Terrain
  • Dentro de las opciones de terreno, elegimos como terreno base Main Sea Level y dejamos el resto por defecto.
  • Al subirse el fichero, ya nos aparecerá un extracto de código para usar este nuevo terreno.

Añadir ortofoto

Repetimos lo anterior, en este caso subiendo el fichero de imagen satélite y eligiendo el tipo de datos Raster Imagery.

En este caso, también se nos indica un extracto de código para utilizar dicha imagen.

Personalizando el visor final

Añadimos todo esto a nuestro fichero .html y lo personalizamos a nuestro gusto. Por ejemplo podemos:

  • Ajustar los elementos contenedores para que ocupen toda la pantalla:
<body style="margin:0;width:100vw;height:100vh;">
<div style="height:100%;" id="cesiumContainer"></div>
  • Esconder los controles inferiores:
viewer.timeline.container.style.visibility = "hidden";
viewer.animation.container.style.visibility = "hidden";
  • Modificar las coordenadas de la vista inicial para centrarla en nuestro emplazamiento:
viewer.camera.flyTo({
    destination : Cesium.Cartesian3.fromDegrees(-17.8195, 28.6052, 3000),
    orientation : {
    heading : Cesium.Math.toRadians(-85.0),
    pitch : Cesium.Math.toRadians(-30.0),
    }
});

El resultado ya lo habrás visto en varias ocasiones:

Y su código completo es este:

<html lang="en">
<head>
<meta charset="utf-8">

<script src="https://cesium.com/downloads/cesiumjs/releases/1.96/Build/Cesium/Cesium.js"></script>
<link href="https://cesium.com/downloads/cesiumjs/releases/1.96/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
</head>
<body style="margin:0;width:100vw;height:100vh;">
<div style="height:100%;" id="cesiumContainer"></div>
<script>
    // Your access token can be found at: https://cesium.com/ion/tokens.
    // Replace `your_access_token` with your Cesium ion access token.

    Cesium.Ion.defaultAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI1MTYwNGM0Yi1iYzlkLTRkMTUtOGQyOS04YjQxNmUwZDQ0YjkiLCJpZCI6MTA0Mjc3LCJpYXQiOjE2NjAxMDk4MzR9.syNWeKPLWA2eMrEh4K9hvkyp1oGdbLMaL0Ozk1kaksk';

    // Initialize the Cesium Viewer in the HTML element with the `cesiumContainer` ID.
    const viewer = new Cesium.Viewer('cesiumContainer', {
      //terrainProvider: Cesium.createWorldTerrain()//terreno original
		terrainProvider: new Cesium.CesiumTerrainProvider({//terreno modificado
			url: Cesium.IonResource.fromAssetId(1255858),
		}),	  
    });    
	
	// Esconder widgest inferiores
	viewer.timeline.container.style.visibility = "hidden";
	viewer.animation.container.style.visibility = "hidden";
	
	// Add Sentinel2 imagery
	const layer = viewer.imageryLayers.addImageryProvider(
	  new Cesium.IonImageryProvider({ assetId: 1256129 })
	);
    // Add Cesium OSM Buildings, a global 3D buildings layer.
    const buildingTileset = viewer.scene.primitives.add(Cesium.createOsmBuildings());   
    // Fly the camera to San Francisco at the given longitude, latitude, and height.
    viewer.camera.flyTo({
      destination : Cesium.Cartesian3.fromDegrees(-17.8195, 28.6052, 3000),
      orientation : {
        heading : Cesium.Math.toRadians(-85.0),
        pitch : Cesium.Math.toRadians(-30.0),
      }
    });
  </script>
</div>
</html>

Y esto es todo! Verás que puedes crear infinidad de aplicaciones web inmersivas con terrenos detallados por los usuarios y más precisos que los genéricos.

¿Se te ocurre qué más hacer con CesiumJS? Cuéntalo en Twitter 🐦

🐦 @RoamingWorkshop

Obtén la temperatura media del mar (o de cualquier mapa ráster)

ACTUALIZACIÓN 2023! Se elimina el servicio WMS horario y se actualiza la versión diaria a V2. Version WMS actualizada a 1.3.0

Estas semanas está de moda comentar el calentamiento que está sufriendo el Mar Mediterráneo y he encontrado muy pocos mapas que me permita pinchar fácilmente sobre él para saber la temperatura que pretende indicar.

Así que he hecho el mío propio:

  1. Vamos a buscar un WMS con la información en formato ráster,
  2. A replicar su leyenda mediante el uso de lienzos <canvas> html,
  3. Y obtener el valor del pixel seleccionado que corresponda con la leyenda.

Los datos

Existen muchas y muy liosas fuentes de información.

  • Puertos del Estado:
    Mi primer intento fue acudir a Puertos del Estado, pero lo único bueno que encontré es que usan Leaflet.. Tienen un visor en tiempo real, pero es muy complicado acceder a los datos para hacer algo con ellos (aunque ya veremos cómo ).
  • Copernicus:
    "Copernicus es el Programa de Observación de la Tierra de la Unión Europea", como bien dicen en su página web, y aglutinan la mayoría de información geográfica y medioambiental producida por los paises miembros de la UE.
    En su sección marítima, encontramos datos de Temperatura Superficial del Mar (SST: Sea Surface Temperature) obtenidas por satélite y otros sensores de diferentes organismos europeos.

Global Ocean OSTIA Diurnal Skin Sea Surface Temperature

Después de probar todos, el más completo es el mapa global generado por la MetOffice (la AEMET inglesa).

Según indican, se muestra la media horaria en un mapa sin huecos, usando datos de campo, satelitales y de medidores infra-rojo.

El mapa base

Voy a crear un mapa base como ya hemos visto en otros post.

Como voy a añadir una capa ráster WMS, que consiste en píxeles de poca resolución, añado una capa de países del mundo de Eurostat.

La descargo en formato geoJSON y, para que sea fácil insertarla, la convierto en una variable llamada "countries" en un nuevo fichero CNTR_RG_01M_2020_4326.js insertándole este encabezado (ten en cuenta que hay que cerrar el objeto JSON con "]}"para que se lea correctamente).

var countries = {
"type": "FeatureCollection",
"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },
"features": [
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[51.590556, 24.242975],   ......   ]}

Creado el fichero, estará disponible para nuestro mapa enlazándolo así en el <head> de nuestro .html:

<script src="./CNTR_RG_01M_2020_4326.js"></script>

TIP! Si tienes problemas creando el archivo de países, descárgalo de este servidor o añade el link completo en el head script

Este mapa base va quedando así:

SST raster data
<!DOCTYPE html>
<html>
<head>

<title>SST raster data</title>

<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌡</text></svg>">

<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css"
   integrity="sha512-hoalWLoI8r4UszCkZ5kL8vayOGVae1oxXe/2A4AO6J9+580uKHDO3JdHb7NzwwzK5xr/Fs0W40kiNHxM9vyTtQ=="
   crossorigin=""/>
   
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"
   integrity="sha512-BB3hKbKWOc9Ez/TAwyWxNXeoV9c1v6FIeYiBieIWkpLjauysF18NzgR1MBNBXf8/KABdlkX68nAhlwcDFLGPCQ=="
   crossorigin=""></script>

<script src="./CNTR_RG_01M_2020_4326.js"></script>

</head>

<style>
body{
	margin:0;
}
#base1{
	width:100vw;
	height:100vh;
}
</style>

<body>
	<div id="base1"></div>
</body>
<script>

	//Crear mapa de Leaflet
	var map = L.map('base1').setView([48, 10], 5);
	
	//Añadimos capa de paises
	L.geoJSON(countries, {	//usa la variable "countries" que está definida en el archivo 'CNTR_RG_01M_2020_4326.js'
		style: function(){	//sobreescribimos el estilo por defecto de Leaflet con algo más estético
			return {
			fillColor: "BurlyWood",
			color: "bisque",
			fillOpacity: 1,
			};
		}
	}).addTo(map);

</script>

</html>

Consultar y añadir un servicio WMS

Voy a añadir el mapa diario global del MetOffice que nos facilita el programa Copernicus.

Para consultar los detalles del WMS, debemos buscar el fichero de metadatos, que en este caso está en la pestaña "DATA-ACCESS".

En este fichero hacemos una búsqueda por "WMS" para encontrar el enlace que nos ofrece este servicio.

Donde vemos que el enlace es el siguiente:

http://nrt.cmems-du.eu/thredds/wms/METOFFICE-GLO-SST-L4-NRT-OBS-SST-V2

A este enlace le añadimos las siguientes funciones de GeoServer "?service=WMS&request=GetCapabilities" lo cual nos dará la información disponible en dicho servicio WMS, como capas, leyendas, estilos, unidades, etc.

http://nrt.cmems-du.eu/thredds/wms/METOFFICE-GLO-SST-L4-NRT-OBS-SST-V2?service=WMS&request=GetCapabilities

Añadimos la capa WMS como nos indica la documentación de Leaflet:

	var base = L.tileLayer.wms(URL, {
		layers: 'analysed_sst', //cambiar la capa. Comprobar con ?service=WMS&request=GetCapabilities
		format: 'image/png',
		transparent: true,
        styles: 'boxfill/rainbow',
        //time: '2022-08-02T16:30:00.000Z',
		attribution: "© <a target='_blank' href='https://resources.marine.copernicus.eu/product-detail/SST_GLO_SST_L4_NRT_OBSERVATIONS_010_001/INFORMATION'>Copernicus Marine Service & MetOffice ◳</a>"
	}).addTo(map);

Para saber que opciones debemos introducir, consultamos el fichero de metadatos, en concreto las etiquetas:

  • <Layer queryable="1"><Name> nos dará el nombre de las capas que introduciremos en la propiedad "layers:"
  • <Style><Name> nos dará los nombres de los distintos estilos disponibles para la representación del ráster, que podemos especificar en la propiedad "styles:".
  • <LegenURL> nos dará el enlace a la leyenda que emplea ese estilo.
  • <Dimension> nos dará unidades que podemos consultar en el servicio WMS. En nuestro caso la unidad es temporal, ya que podemos variar la fecha de los datos representados. Lo voy a dejar comentado, por lo que tomará la última fecha disponible, pero luego lo utilizaremos para personalizar la consulta.
SST raster data

Por último, vamos a añadirle la leyenda al mapa para tener una referencia visual.

La insertamos como imagen en el <body> y añadimos el CSS al <style>:

<style>
body{
	margin:0;
}
#base{
	width:100%;
	height:350px;
}
.leyenda{
	position:absolute;
	width:110px;
	height:264px;
	top:5px;
	right:5px;
	z-Index:1001;
	display:flex;
	justify-content:flex-end;
}
</style>

<body>
    <div class="leyenda">
		<img src="https://nrt.cmems-du.eu/thredds/wms/METOFFICE-GLO-SST-L4-NRT-OBS-SST-V2?REQUEST=GetLegendGraphic&LAYER=analysed_sst&PALETTE=rainbow&transparent=true"></img>
	</div>

	<div id="base"></div>
</body>
SST raster data

Como verás, así es muy complicado intentar averiguar la temperatura de un punto concreto.

Replicar la leyenda para consultar los datos

Para poder conocer la temperatura correspondiente a un color concreto, deberíamos conocer la posición de dicho color en la leyenda y calcular su valor proporcional a los valores extremos (310kPa= 36.85ºC y 210kPa=-3.15ºC).

El problema es que por política CORS, no podemos hacer una consulta sobre la imagen, que está fuera del dominio de nuestra aplicación. Por otro lado, si añadimos la imagen a nuestro dominio, esta tendrá resolución determinada y puede restringir la precisión de los colores consultados.

Por ello, para replicar la leyenda añado un elemento de lienzo o <canvas> junto a la leyenda.

    <div class="leyenda">
		<canvas id="gradientC"></canvas>
		<img src="https://nrt.cmems-du.eu/thredds/wms/METOFFICE-GLO-SST-L4-NRT-OBS-SST-V2?REQUEST=GetLegendGraphic&LAYER=analysed_sst&PALETTE=rainbow&transparent=true"></img>
	</div>

Y mediante javascript, ayudándonos del HTML Color Picker, vamos definiendo las distintas paradas de un relleno tipo "gradiente" que reemplazará a nuestra leyenda (la añado como función para poder usarla de forma dinámica).

	function grad(){
		//Generamos la leyenda en el canvas 'gradientC'
		var ctx=document.getElementById("gradientC").getContext('2d');
		//Definimos un gradiente lineal
		var grd=ctx.createLinearGradient(0,150,0,0);
		//Calibrar las paradas del gradiente tomando muestras de la imagen
		//Descomentar la imagen que va junto al canvas en el html
		//Usar HTML color picker: https://www.w3schools.com/colors/colors_picker.asp
		grd.addColorStop(0, "rgb(0, 0, 146)");			//0 -> -3.15
		grd.addColorStop(0.09, "rgb(0, 0, 247)");		//1
		grd.addColorStop(0.185, "rgb(1, 61, 255)");		//2
		grd.addColorStop(0.26, "rgb(0, 146, 254)");		//3
		grd.addColorStop(0.3075, "rgb(0, 183, 255)");		//4
		grd.addColorStop(0.375, "rgb(3, 251, 252)");	//5
		grd.addColorStop(0.5, "rgb(111, 255, 144)");	//6 -> 20.0
		grd.addColorStop(0.575, "rgb(191, 255, 62)");	//7
		grd.addColorStop(0.64, "rgb(255, 255, 30)");	//8
		grd.addColorStop(0.74, "rgb(255, 162, 1)");		//9
		grd.addColorStop(0.805, "rgb(255, 83, 0)");		//10
		grd.addColorStop(0.90, "rgb(252, 4, 1)");		//11
		grd.addColorStop(1, "rgb(144, 0, 0)");			//12 -> 36.85
		
		//añadir el gradiente al canvas
		ctx.fillStyle = grd;
		ctx.fillRect(0,0,255,255);
	}
	
	//ejecutamos la funcion del gradiente al inicio
	grad();
SST raster data

Crear selector de pixel y calculador de temperatura

Una vez tenemos una leyenda propia, vamos a crear un elemento que muestre el color del pixel seleccionado, así como la temperatura obtenida.

Para poder obtener el color del pixel seleccionado, éste también lo insertamos en un lienzo en el body:

	<canvas id="temp">
		<img id="pixel" src="" ALT="CLICK PARA OBTENER TEMPERATURA"></img>
	</canvas>
	
	<div id="tempTxt">PINCHA SOBRE EL MAR PARA CONOCER SU TEMPERATURA</div>

Y damos un formato adecuado a los nuevos elementos en el <style>:

#temp{
	position:absolute;
	bottom:10px;
	left:10px;
	width:150px;
	height:150px;
	background-color: lightgrey;
	border: 2px solid white;
	border-radius: 5px;
	z-Index: 1001;
}
#tempTxt{
	display:flex;
	position:absolute;
	bottom:10px;
	left:10px;
	width:150px;
	height:150px;
	padding: 2px 2px 2px 2px;
	z-Index:1001;
	font-family:verdana;
	font-size:16px;
	font-weight:bold;
	text-align:center;
	align-items:center;
    word-wrap:break-word;
    word-break:break-word;
}
SST raster data
CLICK PARA OBTENER TEMPERATURA
PINCHA SOBRE EL MAR PARA CONOCER SU TEMPERATURA

Ahora añadimos dos funciones:

  • onMapClick() para obtener el pixel seleccionado y trasladarlo al recuadro inferior. Esto lo hacemos con una petición GET al servicio WMS con las coordenadas correspondientes al pixel donde hacemos click. Es importante tener en cuenta el sistema de referencia que use el mapa del servicio WMS, que en nuestro caso no es el habitual, sino que es EPSG:3857, para la conversión de unidades.
	//Añadimos función al hacer click en el mapa
	map.addEventListener('click', onMapClick);
	
	function onMapClick(e) {
		//ejecutamos la función grad con cada click
		grad();
		//Obtenemos las coordenadas del pinto seleccionado
        var latlngStr = '(' + e.latlng.lat.toFixed(3) + ', ' + e.latlng.lng.toFixed(3) + ')';
		//console.log(latlngStr);

		//Definir el CRS para enviar la consulta al WMS
		const proj = L.CRS.EPSG3857;
        //const proj = L.CRS.EPSG4326;
		
		//Definimos los límites del mapa que pediremos al WMS para que sea aproximadamente de 1 pixel
		var BBOX=((proj.project(e.latlng).x)-10)+","+((proj.project(e.latlng).y)-10)+","+((proj.project(e.latlng).x)+10)+","+((proj.project(e.latlng).y)+10);
		
		//console.log(BBOX);
		
		//Restablecemos la imagen en cada click
		var tTxt=document.getElementById("tempTxt");
		var pix= document.getElementById("pixel");
		var ctx=document.getElementById("temp").getContext("2d");
		//pix.src="";
		ctx.fillStyle="lightgrey";
		ctx.fillRect(0,0,300,300);
		
		//Realizamos la petición del pixel seleccionado
		var xPix= new XMLHttpRequest();
		xPix.onreadystatechange = function(){
			if (this.readyState == 4 && this.status == 200) {
				pix.src=URL+WMS+BBOX;
				pix.onload=function(){
					ctx.drawImage(pix,0,0,300,300);
					tTxt.innerHTML="INTERPRETANDO LEYENDA...";
					//Interpretamos el pixel según la leyenda
					leyenda();
				}
				pix.crossOrigin="anonymous";
			}
		};
		
		xPix.open("GET", URL+WMS+BBOX);
		xPix.send();
		
		tTxt.innerHTML="CARGANDO TEMPERATURA...";
		



    }
  • leyenda() calcula la temperatura que corresponde a dicho pixel seleccionado según la leyenda que hemos creado anteriormente. Muestra el valor de la temperatura en el recuadro inferior y también se indica en la leyenda con una franja blanca.
    El algoritmo de cálculo consiste en recorrer la leyenda pixel a pixel (verticalmente) y comparar la diferencia de los valores rgb(x,y,z) de la leyenda con los valores rgb del pixel seleccionado. Iremos guardando el valor que vaya obteniendo una menor diferencia hasta llegar al final, por lo que habrá casos en que la solución no sea 100% exacta. No es la mejor manera pero es rápida (de entender y de ejecutar) y bastante efectiva.
	function leyenda(){
		var ctx=document.getElementById("temp").getContext("2d");
		var tTxt=document.getElementById("tempTxt");
		//obtenemos el valor RGB del pixel seleccionado
		var RGB=ctx.getImageData(5,5,1,-1).data;
		//console.log(ctx.getImageData(10,10,1,-1).data);
		
		var key=document.getElementById("gradientC").getContext("2d");
		var max=150;
		
		var min=1000;//la máxima diferencia sólo puede ser de 255x3=765
		var dif="";
		var val="";
		
		//recorremos el gradiente de la leyenda pixel a pixel para obtener el valor de temperatura
		for(var p=1;p<=max;p++){
			//obtenemos el valor actual
			var temp=key.getImageData(1,p,1,-1).data;
			//console.log(temp);
			//comparamos con el seleccionado y obtenemos la diferencia total
			dif=Math.abs(parseInt(temp[0])-parseInt(RGB[0]))+Math.abs(parseInt(temp[1])-parseInt(RGB[1]))+Math.abs(parseInt(temp[2])-parseInt(RGB[2]));
			if(dif<min){
				min=dif;
				val=p;
				//console.log("Obj:"+RGB[0]+","+RGB[1]+","+RGB[2]+"\nTemp:"+temp[0]+","+temp[1]+","+temp[2]+"\nDif:"+dif);
			}
		}
		var T=36.85-(val*40/max);
		T=T.toFixed(2);
		//pintamos una línea de referencia en la leyenda
		key.fillStyle="white";
		key.fillRect(0,val,255,1);
		
		//definimos la temperatura en el texto
		//si el color da gris, hemos pinchado en la tierra
		//console.log("T= "+T);
		if(RGB[0]==211&RGB[1]==211&RGB[2]==211){
			tTxt.innerHTML="PINCHA SOBRE EL MAR PARA CONOCER SU TEMPERATURA";
		}else if(typeof T == "undefined"){
			tTxt.innerHTML="¡ERROR!<BR>PRUEBA OTRO PUNTO DEL MAR.";
		}else{
			tTxt.innerHTML="TEMPERATURA APROXIMADA: <br><br>"+T+" ºC";
		}
		//console.log(key.getImageData(1,150,1,-1).data);
	}

Además, omitimos la imagen original de la leyenda y añadimos nuestros propios rótulos.

Por último, añadimos un controlador de fecha en el body:

    <div id="timeBlock">
        <button id="reini" onclick="reiniT()">↺</button>
	    <input type="datetime-local" id="timeInp" name="maptime">
    </div>

Y su CSS correspondiente:

#tempTxt{
	display:flex;
	position:absolute;
	bottom:10px;
	left:10px;
	width:150px;
	height:150px;
	padding: 2px 2px 2px 2px;
	z-Index:1001;
	font-family:verdana;
	font-size:16px;
	font-weight:bold;
	text-align:center;
	align-items:center;
    word-wrap:break-word;
    word-break:break-word;
}
#timeBlock{
    display:flex;
    flex-direction:row;
	position:absolute;
    width:250px;
    top:10px;
    left:50%;
    margin-left:-125px;
    justify-content:center;
	z-Index:1002;
    font-family:verdana;
    font-size:14px;
}
#timeInp{
	width:200px;
    height:30px;
	text-align:center;
}
#reini{
    width:35px;
    height:35px;
    margin-right:5px;
    font-weight:bold;
    font-size:18px;
    padding:0;
}

Así como algunas líneas de javascript para:

  • obtener la última fecha disponible y mostrarla,
  • actualizar el mapa cuando modifiquemos la fecha
  • y definir los valores máximos y mínimos permitidos
	//Obtener última hora de actualizacion del mapa
	var timeInp=document.getElementById("timeInp");
	var t;
    var maxT;
    var minT;
	var xTime = new XMLHttpRequest();
		xTime.onreadystatechange = function(){
			if (this.readyState == 4 && this.status == 200) {
				//convertimos el XML según https://www.w3schools.com/xml/xml_parser.asp
                var xmlDoc=this.responseXML;
                t=xmlDoc.children[0].children[1].children[2].children[12].children[1].children[5].attributes[4].value;
                //Lo convertimos en un objeto fecha quitando los segundos
        		t=new Date(t);
                t=t.toISOString().substring(0,t.toISOString().length-8);
                //Lo pasamos al selector y lo establecemos como máximo
                timeInp.max=t;
                timeInp.value=t;
                maxT=new Date(t);

                //también establecemos el minimo
                t=xmlDoc.children[0].children[1].children[2].children[12].children[1].children[5].innerHTML.trim();
                t=t.substring(0,16);
                timeInp.min=t;
                minT=new Date(t);
			}
		};
		
		xTime.open("GET", URL+"?service=WMS&request=GetCapabilities");
		xTime.send();
	
	//Selector de fecha para WMS
		
	timeInp.addEventListener("change", function(){
		t=new Date(timeInp.value.toString());
		t.setHours(12-t.getTimezoneOffset()/60);

        t=t.toISOString().substring(0,t.toISOString().length-8);
	    timeInp.value=t;
	    t=new Date(timeInp.value);
            t.setHours(12-t.getTimezoneOffset()/60);

        //si estamos en el rango de datos..
        if(t>=minT && t<=maxT){
		    t=t.toISOString();
            //actualziamos el mapa
		    base.setParams({
			    time: t,
			    styles: "boxfill/rainbow",
		    },0);
        }else{//mostramos error
            alert("La fecha introducida está fuera de rango.");
        }
	});

    //funcion de reinicio de fecha
    function reiniT(){
        timeInp.value=maxT.toISOString().substring(0,maxT.toISOString().length-13)+"23:30";
    }

Resultado

Con todo esto, nos quedará algo así, que puedes ver a pantalla completa aquí:

Si te ha gustado, tienes dudas, o alguna idea para mejorar este mapa, deja tus inquietudes en Twitter 🐦

🐦 @RoamingWorkshop

Aquí te dejo el código completo de la aplicación, uniendo todo lo de arriba. ¡Hasta la próxima!

<!DOCTYPE html>
<html>
<head>
<meta charset='UTF-8'\>
<title>SST raster data</title>

<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌡</text></svg>">

<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css"
   integrity="sha512-hoalWLoI8r4UszCkZ5kL8vayOGVae1oxXe/2A4AO6J9+580uKHDO3JdHb7NzwwzK5xr/Fs0W40kiNHxM9vyTtQ=="
   crossorigin=""/>
   
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"
   integrity="sha512-BB3hKbKWOc9Ez/TAwyWxNXeoV9c1v6FIeYiBieIWkpLjauysF18NzgR1MBNBXf8/KABdlkX68nAhlwcDFLGPCQ=="
   crossorigin=""></script>

<script src="./CNTR_RG_01M_2020_4326.js"></script>

</head>

<style>
body{
	margin:0;
}
#temp{
	position:absolute;
	bottom:10px;
	left:10px;
	width:150px;
	height:150px;
	background-color: lightgrey;
	border: 2px solid white;
	border-radius: 5px;
	z-Index: 1001;
}
#map{
	width:100vw;
	height:100vh;
}
.leyenda{
	position:absolute;
	top:10px;
	z-Index:1001;
	display:flex;
	flex-direction:row;
	width:50px;
	height:255px;
	right: 50px;
	font-family:verdana;
	font-size:11px;
}
.leyenda-txt{
	display:flex;
	flex-direction:column;
	align-items: stretch;
	justify-content:center;
	text-align:right;
	font-weight:bold;
	text-shadow: 0 0 6px white;
	margin: 0px 3px 0px 3px;
}
.leyenda-val{
	height:255px;
	margin-top:-5px;
}
#gradientC{
	z-Index=1002;
	width:25px;
	height:255px;
	margin:0;
}
#tempTxt{
	display:flex;
	position:absolute;
	bottom:10px;
	left:10px;
	width:150px;
	height:150px;
	padding: 2px 2px 2px 2px;
	z-Index:1001;
	font-family:verdana;
	font-size:16px;
	font-weight:bold;
	text-align:center;
	align-items:center;
    word-wrap:break-word;
    word-break:break-word;
}
#timeBlock{
    display:flex;
    flex-direction:row;
	position:absolute;
    width:250px;
    top:10px;
    left:50%;
    margin-left:-125px;
    justify-content:center;
	z-Index:1002;
    font-family:verdana;
    font-size:14px;
}
#timeInp{
	width:200px;
    height:30px;
	text-align:center;
}
#reini{
    width:35px;
    height:35px;
    margin-right:5px;
    font-weight:bold;
    font-size:18px;
    padding:0;
}
</style>

<body>

    <div id="timeBlock">
        <button id="reini" onclick="reiniT()">↺</button>
	    <input type="datetime-local" id="timeInp" name="maptime">
    </div>

	<div class="leyenda">
		<div class="leyenda-txt">
			<div class="leyenda-val">36.85</div>
			<div class="leyenda-val">20.0</div>
			<div>-3.15</div>
			</div>
		<!--Descomentar para mostrar la imagen descargada de la leyenda y calibrar el gradiente-->
		<!--<img src="leyenda.jpg"></img>-->
		
		<canvas id="gradientC"></canvas>
			
		<div class="leyenda-txt" style="margin-top:-10px;"><div >(ºC)</div></div>
	</div>
	
	<canvas id="temp">
		<img id="pixel" src="" ALT="CLICK PARA OBTENER TEMPERATURA"></img>
	</canvas>
	
	<div id="tempTxt">PINCHA SOBRE EL MAR PARA CONOCER SU TEMPERATURA</div>
	
	<div id="map"></div>
</body>
<script>

	//Crear mapa de Leaflet
	var map = L.map('map').setView([48, 10], 5);
	
	//Añadimos capa de paises
	L.geoJSON(countries, {	//usa la variable "countries" que está definida en el archivo 'CNTR_RG_01M_2020_4326.js'
		style: function(){	//sobreescribimos el estilo por defecto de Leaflet con algo más estético
			return {
			fillColor: "BurlyWood",
			color: "bisque",
			fillOpacity: 1,
			};
		}
	}).addTo(map);
	
	//Añadimos servicio WMS siguiendo https://leafletjs.com/examples/wms/wms.html
	
	//OSTIA Global daily mean SST
	var URL="https://nrt.cmems-du.eu/thredds/wms/METOFFICE-GLO-SST-L4-NRT-OBS-SST-V2"
	var WMS = '?service=WMS&request=GetMap&version=1.3.0&layers=analysed_sst&styles=&format=image%2Fpng&transparent=true&width=200&height=200&CRS=EPSG:3857&bbox=';
	
	//OSTIA Global hourly mean diurnal skin SST
	//var URL="https://nrt.cmems-du.eu/thredds/wms/METOFFICE-GLO-SST-L4-NRT-OBS-SKIN-DIU-FV01.1"
	//var WMS = '?service=WMS&request=GetMap&version=1.1.1&layers=analysed_sst&styles=&format=image%2Fpng&transparent=true&width=200&height=200&srs=EPSG:3857&bbox=';

    //Global Ocean - SST Multi-sensor L3 Observations
    //var URL="http://nrt.cmems-du.eu/thredds/wms/IFREMER-GLOB-SST-L3-NRT-OBS_FULL_TIME_SERIE";
    //var WMS="?service=WMS&request=GetMap&version=1.1.1&layers=adjusted_sea_surface_temperature&styles=&format=image%2Fpng&transparent=true&width=200&height=200&srs=EPSG:4326&bbox=";
	
	var base = L.tileLayer.wms(URL, {
		layers: 'analysed_sst', //cambiar la capa. Comprobar con ?service=WMS&request=GetCapabilities
		format: 'image/png',
		transparent: true,
        styles: 'boxfill/rainbow',
    	version: "1.3.0",
        //time: '2022-08-02T16:00:00.000Z',
		attribution: "© <a target='_blank' href='https://resources.marine.copernicus.eu/product-detail/SST_GLO_SST_L4_NRT_OBSERVATIONS_010_001/INFORMATION'>Copernicus Marine Service & MetOffice ◳</a>"
	}).addTo(map);
	
	//Obtener última hora de actualizacion del mapa
	var timeInp=document.getElementById("timeInp");
	var t;
    var maxT;
    var minT;
	var xTime = new XMLHttpRequest();
		xTime.onreadystatechange = function(){
			if (this.readyState == 4 && this.status == 200) {
				//convertimos el XML según https://www.w3schools.com/xml/xml_parser.asp
                var xmlDoc=this.responseXML;
                t=xmlDoc.children[0].children[1].children[2].children[12].children[1].children[5].attributes[4].value;
                //Lo convertimos en un objeto fecha quitando los segundos
        		t=new Date(t);
                t=t.toISOString().substring(0,t.toISOString().length-8);
            	console.log(t);
                //Lo pasamos al selector y lo establecemos como máximo
                timeInp.max=t;
                timeInp.value=t;
                maxT=new Date(t);

                //también establecemos el minimo
                t=xmlDoc.children[0].children[1].children[2].children[12].children[1].children[5].innerHTML.trim();
                t=t.substring(0,16);
                timeInp.min=t;
                minT=new Date(t);
			}
		};
		
		xTime.open("GET", URL+"?service=WMS&request=GetCapabilities");
		xTime.send();
	
	//Selector de fecha para WMS
		
	timeInp.addEventListener("change", function(){
		t=new Date(timeInp.value.toString());
        t.setHours(12-t.getTimezoneOffset()/60);
        t=t.toISOString().substring(0,t.toISOString().length-8);
	    timeInp.value=t;
	    t=new Date(timeInp.value);
    	t.setHours(12-t.getTimezoneOffset()/60);
        //t=t.toISOString().substring(0,t.toISOString().length-13)+"14:00";

        //si estamos en el rango de datos..
        if(t>=minT && t<=maxT){
		    t=t.toISOString();
            //actualziamos el mapa
		    base.setParams({
			    time: t,
			    styles: "boxfill/rainbow",
		    },0);
        }else{//mostramos error
            alert("La fecha introducida está fuera de rango.");
        }
	});

    //funcion de reinicio de fecha
    function reiniT(){
        timeInp.value=maxT.toISOString().substring(0,maxT.toISOString().length-13)+"12:00";
    }

    //Generar la leyenda
	
	function grad(){
		//Generamos la leyenda en el canvas 'gradientC'
		var ctx=document.getElementById("gradientC").getContext('2d');
		//Definimos un gradiente lineal
		var grd=ctx.createLinearGradient(0,150,0,0);
		//Calibrar las paradas del gradiente tomando muestras de la imagen
		//Descomentar la imagen que va junto al canvas en el html
		//Usar HTML color picker: https://www.w3schools.com/colors/colors_picker.asp
		grd.addColorStop(0, "rgb(0, 0, 146)");			//0 -> -3.15
		grd.addColorStop(0.09, "rgb(0, 0, 247)");		//1
		grd.addColorStop(0.185, "rgb(1, 61, 255)");		//2
		grd.addColorStop(0.26, "rgb(0, 146, 254)");		//3
		grd.addColorStop(0.3075, "rgb(0, 183, 255)");		//4
		grd.addColorStop(0.375, "rgb(3, 251, 252)");	//5
		grd.addColorStop(0.5, "rgb(111, 255, 144)");	//6 -> 20.0
		grd.addColorStop(0.575, "rgb(191, 255, 62)");	//7
		grd.addColorStop(0.64, "rgb(255, 255, 30)");	//8
		grd.addColorStop(0.74, "rgb(255, 162, 1)");		//9
		grd.addColorStop(0.805, "rgb(255, 83, 0)");		//10
		grd.addColorStop(0.90, "rgb(252, 4, 1)");		//11
		grd.addColorStop(1, "rgb(144, 0, 0)");			//12 -> 36.85
		
		//añadir el gradiente al canvas
		ctx.fillStyle = grd;
		ctx.fillRect(0,0,255,255);
	}
	
	//ejecutamos la funcion del gradiente al inicio
	grad();
	
	//Añadimos función al hacer click en el mapa
	map.addEventListener('click', onMapClick);
	
	function onMapClick(e) {
		//ejecutamos la función grad con cada click
		grad();
		//Obtenemos las coordenadas del pinto seleccionado
        var latlngStr = '(' + e.latlng.lat.toFixed(3) + ', ' + e.latlng.lng.toFixed(3) + ')';
		//console.log(latlngStr);

		//Definir el CRS para enviar la consulta al WMS
		const proj = L.CRS.EPSG3857;
        //const proj = L.CRS.EPSG4326;
		
		//Definimos los límites del mapa que pediremos al WMS para que sea aproximadamente de 1 pixel
		var BBOX=((proj.project(e.latlng).x)-10)+","+((proj.project(e.latlng).y)-10)+","+((proj.project(e.latlng).x)+10)+","+((proj.project(e.latlng).y)+10);
		
		//Restablecemos la imagen en cada click
		var tTxt=document.getElementById("tempTxt");
		var pix= document.getElementById("pixel");
		var ctx=document.getElementById("temp").getContext("2d");
		ctx.fillStyle="lightgrey";
		ctx.fillRect(0,0,300,300);
		
		//Realizamos la petición del pixel seleccionado
		var xPix= new XMLHttpRequest();
		xPix.onreadystatechange = function(){
			if (this.readyState == 4 && this.status == 200) {
				pix.src=URL+WMS+BBOX;
				pix.onload=function(){
					ctx.drawImage(pix,0,0,300,300);
					tTxt.innerHTML="INTERPRETANDO LEYENDA...";
					//Interpretamos el pixel según la leyenda
					leyenda();
				}
				pix.crossOrigin="anonymous";
			}
		};
		
		xPix.open("GET", URL+WMS+BBOX);
		xPix.send();
		
		tTxt.innerHTML="CARGANDO TEMPERATURA...";
		



    }
	function leyenda(){
		var ctx=document.getElementById("temp").getContext("2d");
		var tTxt=document.getElementById("tempTxt");
		//obtenemos el valor RGB del pixel seleccionado
		var RGB=ctx.getImageData(5,5,1,-1).data;
		
		var key=document.getElementById("gradientC").getContext("2d");
		var max=150;
		
		var min=1000;//la máxima diferencia sólo puede ser de 255x3=765
		var dif="";
		var val="";
		
		//recorremos el gradiente de la leyenda pixel a pixel para obtener el valor de temperatura
		for(var p=1;p<=max;p++){
			//obtenemos el valor actual
			var temp=key.getImageData(1,p,1,-1).data;
			//comparamos con el seleccionado y obtenemos la diferencia total
			dif=Math.abs(parseInt(temp[0])-parseInt(RGB[0]))+Math.abs(parseInt(temp[1])-parseInt(RGB[1]))+Math.abs(parseInt(temp[2])-parseInt(RGB[2]));
			if(dif<min){
				min=dif;
				val=p;
			}
		}
		var T=36.85-(val*40/max);
		T=T.toFixed(2);
		//pintamos una línea de referencia en la leyenda
		key.fillStyle="#ffffff";
		key.fillRect(0,val,255,1);
		
		//definimos la temperatura en el texto
		//si el color da gris, hemos pinchado en la tierra
		if(RGB[0]==211&RGB[1]==211&RGB[2]==211){
			tTxt.innerHTML="PINCHA SOBRE EL MAR PARA CONOCER SU TEMPERATURA";
		}else if(typeof T == "undefined"){
			tTxt.innerHTML="¡ERROR!<BR>PRUEBA OTRO PUNTO DEL MAR.";
		}else{
			tTxt.innerHTML="TEMPERATURA APROXIMADA: <br><br>"+T+" ºC";
		}
	
	}

</script>

</html>

Todas las estaciones de AEMET en tu móvil con un click

La Agencia Estatal de Meteorología española (AEMET) cuenta con una API de datos abiertos con la que podemos acceder a la mayoría de datos que publican en su web.

API de AEMET OpenData

De esta forma, cualquier usuario puede crear apps muy sencillas sólo con la información que necesita y sin tener que acceder a su web.

En mi caso, quiero mostrar de forma intuitiva los datos de las estaciones en un mapa web que pueda ver desde cualquier dispositivo con internet como mi móvil. Aquí un anticipo para que sigas leyendo:

Definir un mapa base

Como no podía ser de otra forma, vamos a usar todo el potencial de Leaflet JS y los mapas abiertos del Centro de Decargas del IGN.

En este caso, sólo necesitamos un mapa muy sencillo, por lo que:

  1. Creamos nuestro documento .html con su estructura básica:
<!DOCTYPE HTML>
<html>

<head>

<style>

</style>

</head>

<body>

</body>

<script>

</script>

</html>
  1. Añadimos los enlaces a las librerías de Leaflet dentro de las etiquetas <head>:

También añado las propiedades <meta> charset(1) y viewport(2) para (1)interpretar correctamente los caracteres especiales como tildes y, (2) ajustar correctamente la ventana a dispositivos móviles.

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

    <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css"
       integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
       crossorigin=""/>
    <script src="https://unpkg.com/[email protected]/dist/leaflet.js"
       integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
       crossorigin=""></script>

<style>

</style>

</head>
  1. Definimos la ubicación del mapa de Leaflet dentro del <body>
<body style="border:0;margin:0;">
    
    <div id="mapa" style="width:100vw; height:100vh;z-index:0"></div>

</body>

Como verás, añado border:0 y margin:0 al estilo del <body>. Esto ajusta el mapa perfectamente a la ventana, sin ningún tipo de espacio en blanco.

Height:100vh y width:100vw ajustarán el mapa al 100% del tamaño de la ventana (view height y view width). El z-index nos servirá para ordenar los objetos y que no se tapen entre ellos.

  1. Ya podemos generar el mapa mediante javascript. Pasamos al bloque <script>

Con L.map definimos el mapa y su vista inicial (que he centrado en Madrid).

Con L.tileLayer.wms añadimos el servicio WMS del IGN para obtener un mapa base "online". Puedes encontrar las URL completas de cada servicio consultando los metadatos que acompañan a cada producto.

Además, es habitual que los WMS cuenten con varias capas, por lo que debemos definir una. Podemos obtener el listado de capas usando la función GetCapabilities de los wms. En nuestro caso usamos la <Layer> con <Name> "IGNBaseTodo-gris" que identificamos aquí:

https://www.ign.es/wms-inspire/ign-base?service=WMS&request=GetCapabilities

<script>
var o = L.map('mapa').setView([40.5, -3.5], 8);

var IGN = window.L.tileLayer.wms("https://www.ign.es/wms-inspire/ign-base", {
    		layers: 'IGNBaseTodo-gris',
    		format: 'image/png',
    		transparent: true,
    		attribution: "BCN IGN © 2022"
		});
IGN.addTo(o);
</script>

Uniendo todo esto ya tendremos un mapa básico:

Aplicación leafMET.js

Ahora añadimos el plugin de descarga y representación de datos de AEMET leafMET que encontrarás en mi github.

Podemos descargar el fichero leafMET.js y añadirlo como script local, o podemos enlazarlo directamente desde esta web. Dentro de las etiquetas <head> añadimos lo siguiente:

<script type="application/javascript" src="https://theroamingworkshop.cloud/leafMET/leafMET.js"></script>

Acceso directo .html

Ahora, para ver la aplicación con un sólo click desde el móvil, debemos copiar el contenido de leafMET.js dentro de las etiquetas <script> del documento .html

Así, tendremos un único archivo con todo lo necesario para correr nuestra webapp. He añadido las etiquetas <title> y "icon" en <head> para mostrar un título de página y que el acceso directo herede el icono que definamos.

Debería quedar algo así:

<!DOCTYPE HTML>
<html lang="en">

<head>
    <title>leafMET</title> 
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

    <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌤</text></svg>">

    <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css"
       integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
       crossorigin=""/>
    <script src="https://unpkg.com/[email protected]/dist/leaflet.js"
       integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
       crossorigin=""></script>

    <script type="application/javascript" src="https://theroamingworkshop.cloud/leafMET/leafMET.js"></script>

<style>


</style>
</head>

<body style="border:0;margin:0;">
    
    <div id="mapa" style="width:100vw; height:100vh;z-index:0"></div>

</body>

<script>
//Create Leaflet map and load OSM layer
var o = L.map('mapa').setView([40.5, -3.5], 8);

        var IGN = window.L.tileLayer.wms("https://www.ign.es/wms-inspire/ign-base", {
    		layers: 'IGNBaseTodo-gris',
    		format: 'image/png',
    		transparent: true,
    		attribution: "BCN IGN © 2022"
		});
IGN.addTo(o);


leafMET();
o.on('moveend', colocaEnLaVista);

var datosMET=null;
var sta=[];
var pinta="cross";
var ini=0;

function leafMET(){

    //Create button
    var btmet = window.document.createElement("BUTTON");
    btmet.id="btmet";
    btmet.title="Cargar datos meteorológicos";
    btmet.innerHTML="<b>🌤</b>";
    btmet.style.zIndex="1000";
    btmet.style.position="absolute";
    btmet.style.top="100px";
    btmet.style.left="10px";
    btmet.style.fontSize="16px";
	btmet.style.textAlign="center";
    btmet.style.width="35px";
    btmet.style.height="35px";
    btmet.style.background="Turquoise";
	btmet.style.border="0px solid black";
    btmet.style.borderRadius="5px";
    btmet.style.cursor="pointer";
    btmet.addEventListener("click", pintaMET);
    window.document.body.appendChild(btmet);
    //Create subtitle
    var mtxt = window.document.createElement("P");
    mtxt.id="mtxt";
    mtxt.innerHTML="<b>Carga</b>";
    mtxt.title="Cargar datos meteorológicos";
    mtxt.style.zIndex="1000";
    mtxt.style.position="absolute";
    mtxt.style.top="130px";
    mtxt.style.left="7px";
    mtxt.style.fontSize="10px";
	mtxt.style.textAlign="center";
    mtxt.style.width="40px";
    mtxt.style.height="15px";
    mtxt.style.background="DarkOrange";
	mtxt.style.border="0px solid black";
    mtxt.style.borderRadius="2px";
    mtxt.style.cursor="context-menu";
    mtxt.style.fontFamily="Arial";
    window.document.body.appendChild(mtxt);
    //Create key
    for (var i=1; i<=6;i++){
        var mkey = window.document.createElement("P");
        mkey.id="mkey"+i;
        mkey.innerHTML="<b>"+i+"</b>";
        mkey.style.zIndex="1000";
        mkey.style.position="absolute";
        var pos = 140+i*16;
        pos = pos+"px";
        mkey.style.top=pos;
        mkey.style.left="7px";
        mkey.style.fontSize="10px";
	    mkey.style.textAlign="center";
	    mkey.style.textIndent="0px";
        mkey.style.width="40px";
        mkey.style.height="15px";
        mkey.style.background="DarkOrange";
        mkey.style.opacity="0.75";
	    mkey.style.border="0px solid black";
        mkey.style.borderRadius="2px";
        mkey.style.cursor="context-menu";
        mkey.style.fontFamily="Arial";
        mkey.style.fontWeight="bold";
        mkey.style.color="black";
        mkey.style.display="none";
        window.document.body.appendChild(mkey);
    }
}

//Create loading message
function loader(){
    var ldmet = window.document.createElement("P");
    ldmet.id="ldmet";
    ldmet.innerHTML="CARGANDO DATOS METEOROLÓGICOS";
    ldmet.style.zIndex="1000";
    ldmet.style.position="relative";
    ldmet.style.top="-50vh";
    ldmet.style.width="250px";
	ldmet.style.margin="auto";
	ldmet.style.textAlign="center";
    ldmet.style.background="DarkOrange";
    ldmet.style.fontWeight="bold";
    ldmet.style.fontSize="11px";
    ldmet.style.padding="4px 4px 4px 4px";
    ldmet.style.fontFamily="Arial";
    window.document.body.appendChild(ldmet);
}


function getAEMET(){
    //show loading message
    loader();
    //http GET request to data
    var data = null;

    var xhr = new XMLHttpRequest();
    var xhd = new XMLHttpRequest();
    xhr.withCredentials = true;
    xhd.withCredentials = true;

    xhr.onload= function () {
        var consulta= JSON.parse(this.responseText);
        xhd.open("GET", consulta.datos);
        xhd.send();
    }
    xhd.onload= function () {
        data= JSON.parse(this.responseText);
        loadAEMET(data);
    }

    xhr.open("GET", "https://opendata.aemet.es/opendata/api/observacion/convencional/todas?api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJyb21hbmhkZXpnb3JyaW5AZ21haWwuY29tIiwianRpIjoiZmFiMTM1N2QtNTJhMC00ZWQ1LWFkNzYtNjY5YTAzNGI4YTFlIiwiaXNzIjoiQUVNRVQiLCJpYXQiOjE1NzAxMjM2MzIsInVzZXJJZCI6ImZhYjEzNTdkLTUyYTAtNGVkNS1hZDc2LTY2OWEwMzRiOGExZSIsInJvbGUiOiIifQ.-7vQF_TJLghx3g4t3GiHzlWt52LpMChqqtfNUhW07LQ");
    xhr.setRequestHeader("cache-control", "no-cache");
    xhr.send(data);
}

function loadAEMET(d){
    var obj=d.length;

    //loop to plot stations (s) as circles using station coordinate data
    for (var s=0; s < obj; s++){
        sta[s]=window.L.circle([d[s].lat, d[s].lon],{radius: 5000, weight: 0, opcaity: 0.0, fillColor: "RoyalBlue", fillOpacity: 0.01});
        //add name data
        sta[s].title=d[s].ubi;
        //add data values (temp, wind speed and rain)
        sta[s].temp=d[s].ta;
        sta[s].viento=d[s].vmax;
        sta[s].lluvia=d[s].prec;

        var prop="<p><b>"+d[s].ubi+"</b></p>";
        prop=prop+"<p>"+d[s].fint+"</p>";
        prop=prop+"<p><b>Temperatura: </b>"+sta[s].temp+" ºC</p>";
        prop=prop+"<p><b>Viento: </b>"+sta[s].viento+" km/h</p>";
        prop=prop+"<p><b>Lluvia: </b>"+sta[s].lluvia+" mm</p>";
        sta[s].bindPopup(prop);
    }
    //remove loading message
    window.document.getElementById("ldmet").style.display="none";
    //update stations visible in view
    colocaEnLaVista();
}

function colocaEnLaVista(){
    //Plot only stations visible in view bounds
    var vista=o.getBounds();
    for (var s in sta){
    sta[s].setStyle({fillOpacity: 0.01,fill: 0.01});
        if(vista.contains(sta[s].getLatLng())){
            var cobertura=o.distance(o.getBounds().getNorthEast(),o.getBounds().getSouthEast());
            sta[s].setRadius(cobertura/30);
            sta[s].addTo(o);
        }else if(sta[s]){
            sta[s].remove();
        }
    //edit stations display color according to data value
    switch(pinta){
    case "viento":
        if (sta[s].viento==null || sta[s].viento<0){
            sta[s].setStyle({fillOpacity: 0.0,fill: 0.0, cursor: "none"});
        }else if (sta[s].viento>=0 && sta[s].viento<5){
            sta[s].setStyle({fillColor: "green"});
        }else if (sta[s].viento>=5 && sta[s].viento<10){
            sta[s].setStyle({fillColor: "yellow"});
        }else if (sta[s].viento>=10 && sta[s].viento<20){
            sta[s].setStyle({fillColor: "orange"});
        }else if (sta[s].viento>=20 && sta[s].viento<30){
            sta[s].setStyle({fillColor: "red"});
        }else if (sta[s].viento>=30 && sta[s].viento<500){
            sta[s].setStyle({fillColor: "purple"});
        }
    break;
    case "lluvia":
        if (sta[s].lluvia==null || sta[s].lluvia<=0){
            sta[s].setStyle({fillOpacity: 0.0, fill: 0.0, cursor: "none"});
        }else if (sta[s].lluvia>0 && sta[s].lluvia<2){
            sta[s].setStyle({fillColor: "LightCyan"});
        }else if (sta[s].lluvia>=2 && sta[s].lluvia<5){
            sta[s].setStyle({fillColor: "LightSkyBlue"});
        }else if (sta[s].lluvia>=5 && sta[s].lluvia<10){
            sta[s].setStyle({fillColor: "SkyBlue"});
        }else if (sta[s].lluvia>=10 && sta[s].lluvia<20){
            sta[s].setStyle({fillColor: "DodgerBlue"});
        }else if (sta[s].lluvia>=20 && sta[s].lluvia<30){
            sta[s].setStyle({fillColor: "Blue"});
        }else if (sta[s].lluvia>=30 && sta[s].lluvia<500){
            sta[s].setStyle({fillColor: "DarkBlue"});
        }
    break;
    case "temp":
        if (sta[s].temp==null || sta[s].temp<=0){            
            sta[s].setStyle({fillOpacity: 0.0, fill: 0.0, cursor: "none"});
        }else if (sta[s].temp>0 && sta[s].temp<5){
            sta[s].setStyle({fillColor: "LightCyan"});
        }else if (sta[s].temp>=5 && sta[s].temp<10){
            sta[s].setStyle({fillColor: "DodgerBlue"});
        }else if (sta[s].temp>=10 && sta[s].temp<20){
            sta[s].setStyle({fillColor: "SpringGreen"});
        }else if (sta[s].temp>=20 && sta[s].temp<30){
            sta[s].setStyle({fillColor: "Gold"});
        }else if (sta[s].temp>=30 && sta[s].temp<100){
            sta[s].setStyle({fillColor: "DarkOrange"});
        }
    break;
    case "cross":     
            sta[s].remove();
    break;
    }
    }
}

function pintaMET(){
    //only download data at start
	if(ini==0){
    getAEMET();
    ini=1;
    }
    //empty legend
    for (var i=1; i<=6;i++){
        window.document.getElementById("mkey"+i).style.display= "none";
        window.document.getElementById("mkey"+i).style.color="black";
        }
    //generate new legend for each type of data
    //(using the current icon to define the next key)
    if (pinta=="cross"){
        pinta="temp";
        window.document.getElementById("btmet").innerHTML="🌡";
    	window.document.getElementById("btmet").title="Temperatura (ºC)";
        window.document.getElementById("mtxt").innerHTML="<b>T (ºC)</b>";
        window.document.getElementById("mtxt").title="Temperatura (ºC)";
        //set key
        mkey1.style.display="block";
        mkey1.style.background="LightCyan";
        mkey1.innerHTML="0 - 5";
        mkey2.style.display="block";
        mkey2.style.background="DodgerBlue";
        mkey2.innerHTML="5 - 10";
        mkey3.style.display="block";
        mkey3.style.background="SpringGreen";
        mkey3.innerHTML="10 - 20";
        mkey4.style.display="block";
        mkey4.style.background="Gold";
        mkey4.innerHTML="20 - 30";
        mkey5.style.display="block";
        mkey5.style.background="DarkOrange";
        mkey5.innerHTML="> 30";
    }else if(pinta=="temp"){
        pinta="viento";
        window.document.getElementById("btmet").innerHTML="🌪";
    	window.document.getElementById("btmet").title="Viento (Km/h)";
        window.document.getElementById("mtxt").innerHTML="<b>V(km/h)</b>";
        window.document.getElementById("mtxt").title="Viento (Km/h)";
        //set key
        mkey1.style.display="block";
        mkey1.style.background="green";
        mkey1.innerHTML="0 - 5";
        mkey2.style.display="block";
        mkey2.style.background="yellow";
        mkey2.innerHTML="5 - 10";
        mkey3.style.display="block";
        mkey3.style.background="orange";
        mkey3.innerHTML="10 - 20";
        mkey4.style.display="block";
        mkey4.style.background="red";
        mkey4.innerHTML="20 - 30";
        mkey4.style.color="white";
        mkey5.style.display="block";
        mkey5.style.background="purple";
        mkey5.innerHTML="> 30";
        mkey5.style.color="white";
    }else if(pinta=="lluvia"){
        pinta="cross";
        window.document.getElementById("btmet").innerHTML="❌";
    	window.document.getElementById("btmet").title="Sin clima";
        window.document.getElementById("mtxt").innerHTML="<b>-</b>";
        window.document.getElementById("mtxt").title="Sin clima";
    }else if(pinta=="viento"){
        pinta="lluvia";
        window.document.getElementById("btmet").innerHTML="☔";
    	window.document.getElementById("btmet").title="Precipitación (l/m²)";
        window.document.getElementById("mtxt").innerHTML="<b>P (mm)</b>";
        window.document.getElementById("mtxt").title="Precipitación (l/m²)";
        //set key
        mkey1.style.display="block";
        mkey1.style.background="LightCyan";
        mkey1.innerHTML="0 - 2";
        mkey2.style.display="block";
        mkey2.style.background="LightSkyBlue";
        mkey2.innerHTML="2 - 5";
        mkey3.style.display="block";
        mkey3.style.background="SkyBlue";
        mkey3.innerHTML="5 - 10";
        mkey4.style.display="block";
        mkey4.style.background="DodgerBlue";
        mkey4.innerHTML="10 - 20";
        mkey5.style.display="block";
        mkey5.style.background="Blue";
        mkey5.innerHTML="20 - 30";
        mkey5.style.color="white";
        mkey6.style.display="block";
        mkey6.style.background="DarkBlue";
        mkey6.innerHTML="> 30";
        mkey6.style.color="white";
    }
    //update stations visible in view
    colocaEnLaVista();
}


</script>

</html>

También puedes descargarte el fichero leafMET.html de mi github directamente en tu móvil y listo!

¿Te ha servido este post? ¿Tienes dudas o comentarios? Pásate por Twitter y hazme llegar tus inquietudes! ¡Hasta otra!

🐦 @RoamingWorkshop

« Entradas anteriores

© 2025 The Roaming Workshop

Tema por Anders NorenArriba ↑