How I built a wind map with MTS

Taku
8 min readMar 16, 2022

--

Intro

I have been challenged with a question “how do you visualize wind data?”. It’s not a simple answer because there are several factors to consider: performance, portability to other platforms, and data availability. One approach is to use Web GL which has been answered before. While this is performant, some challenges still remain:

  • How do you implement this in other platforms like mobile SDKs?
  • How does the application need to manage fetching the coordinate data and mapping it to the corresponding locations?

After inspecting this animation example and looking at the performance of vector tiles through MTS, I became more confident I can solve this question within the box (no pun intended).

Tl;dr. What does the final output look like?

The final demo is here.

You can inspect the client-side code by removing the /show from the URL. The code that generated the underlying data which is uploaded to Mapbox is located in this repository. I will explain more below how and why this works as intended..

There are several tricks that make this demo work smoothly and I will explain below how these are achieved thru this blog:

  • The wind is converted from a point to an animatable line.
  • The amount and the length of wind data varies across zoom levels.
  • Mapbox API delivers the wind data to the client-side which minimizes latency.

How to build animatable data?

To build an animation, it requires a starting point, an ending point, and all the points in between. In a perfect world, a data provider would have the coordinates for each segment of the wind’s movement.

In this demonstration, I will only use wind direction (in degrees) and the point data (latitude and longitude) taken from Open Weather Map’s historical data which has 200,000+ locations of weather temperature, humidity, wind and more.

Using the wind’s location and the direction, I can convert into a series of points in the direction. In the illustration below, the shapes in pink are the original data and the gray is the series of points converted.

There are two concepts in making the animation look smooth: steps and length. For the image above, I am using 4 steps as there are 4 points formed from one point. In the demo, I am using 25 steps. Length is the length in meters of the two farthest points in the steps. In the demo the length varies across zoom levels because geographical distance increases per pixel at lower zoom levels, making the animation look less smooth. In an ideal situation, having a different length for each zoom level would make the animation look perfect. For this example, I categorize the following zoom levels into three segments with their length:

  • Low-zoom (z=3–6): length of 180,000 meters
  • Mid-zoom (z=6–9): length of 20,000 meters
  • High-zoom (z=9–22): length of 6,000 meters

If the length is too long and the steps are too short, then the animation looks jerky because the gap between the points is too large. On the contrary, if the length is too short and the steps are too high, the animatable line doesn’t look like it’s moving (although increasing the animation frames can solve this).

How to make the animation performant?

Before explaining about the animation performance, I didn’t want to host my own server, and I wanted to delegate the API and data management to Mapbox as much as possible. That is why I upload this wind data to MTS which builds vector tiles, and those vector tiles are then served to the client through Mapbox’s API as tilesets. On the client-side, I will only need to specify the tileset ID and tell the client-side to render how I style it.

Regarding performance, there are 200,000+ data points and Mapbox SDK fetches the tiles, where visible, and so at lower zoom levels, all the data points need to be fetched which is a performance bottleneck. For each tile, there is a 1250kb limit when serving them as vector tiles. Even if this limit did not exist, the client side performance can deteriorate depending on the client’s hardware. Once it goes over the 1250kb limit, some data is lost which creates gaps in my animation, making it unusable. (I can check for this warning in the MTS’s API or verify this in the Tilesets detail page.)

As discussed, at higher zoom levels, the tile sizes are smaller so it’s likely the amount of data transferred per tile is small, not reaching this limit. However, at lower zoom levels, the tile is larger and so it’s likely that it can reach this limit. So, my goal is to keep the maximum data to around 500kb per tile for all zoom levels.

The only workaround is to reduce the amount of data for lower zoom levels. So, I used geohash to index and extracted a point per each geohash tile. I took the first point that existed in each geohash. For a more precise illustration, one can take the average coordinate and average wind direction of the points in the tile. Below is an illustration of indexing and how the points are represented depending on the zoom level.

These are the settings I found to be best for this demo:

  • Low-zoom (z=3–6): Geohash precision of 3 (maximum error of 78km)
  • Mid-zoom (z=6–9): Geohash precision of 4 (maximum error of 20km)
  • High-zoom (z=9–22): No geohashing and show all points in the tile

By geohashing and uploading three tilesets to MTS, I am able to reduce the maximum tile size to 500kb.

How to generate data?

As I mentioned above, I generated three sets of data, low-zoom, mid-zoom, and high-zoom from the source data. This code is located here.

How to upload to MTS?

I generated three sets of data, low-zoom, mid-zoom, and high-zoom from the source data using this script. I didn’t choose to upload one set of data and filter them by an attribute on the client-side because this would mean all the data still needs to be served over the wire, hitting the maximum tile size limit at lower zoom levels. For MTS recipe, I used maxzoom=10 because I didn’t mind having an error of 10 meters. (zoom level error table). This is an example of the recipe for lower zoom.

recipe_lowzoom.json{  "recipe": {    "version": 1,    "layers": {      "wind-line": {        "source": "mapbox://tileset-source/takutosuzukimapbox/wind-120000-planet-25",        "minzoom": 0,        "maxzoom": 10,        "features": {          "simplification": 0        }      }    }  },  "name": "Wind all zooms",  "description": "",  "attribution": []}

To upload to MTS, I uploaded the source, created a tileset, and published it. This part is straight forward. This script is located here.

export SK_TOKEN="sk.DO_NOT_SHARE_SK_TOKENS"# Upload high zoom level datasetcurl -X POST "https://api.mapbox.com/tilesets/v1/sources/takutosuzukimapbox/wind-3000-planet-25?access_token=${SK_TOKEN}"  -F file=@wind_highzoom.geojsonld  --header "Content-Type: multipart/form-data"curl -X POST "https://api.mapbox.com/tilesets/v1/takutosuzukimapbox.wind-highzoom-planet?access_token=${SK_TOKEN}"  -d @recipe_highzoom.json  --header "Content-Type:application/json"curl -X POST "https://api.mapbox.com/tilesets/v1/takutosuzukimapbox.wind-highzoom-planet/publish?access_token=${SK_TOKEN}"  -d @recipe_highzoom.json  --header "Content-Type:application/json"

Now that the difficult parts are solved, I just need to implement this on the client-side.

How to implement the animation on the client-side?

I implemented this on the client-side in about 100 lines; half of it is just instantiating the layer and the other is looping over to update animation values. All I need to do is:

  1. Create a circle layer for the three zoom levels (low-zoom, med-zoom, and high-zoom)
  2. Create a perpetual timer and update the layer to apply appropriate colors.

Adding a circle layer is straight forward. I chose a circle layer for a demonstration purpose but I could have chosen a symbol layer (and add nice icons) or use a line layer.

Creating a timer is required because the colors of the circle need to change every 200 milliseconds but this timing can be adjusted to make the animation run faster. (A more robust approach is to call requestAnimationFrame instead of setInterval)

// update colorssetInterval(() => {  currentSliderIdx++  updateLayer(currentSliderIdx)  if (currentSliderIdx > 16) {    currentSliderIdx = 1  }}, 200)function updateLayer(layerId) {  const tailLen = 5  const tailCircleColors = Array(tailLen).fill().map((v, i) => [    layerId - (i + 1),    "hsla(0, 14%, 93%, 0.15)",  ]).flat()  const circleColor = [    "match",    ["get", "value"],    ...tailCircleColors,    [layerId],    "hsla(0, 0%, 100%, 0.3)",    "hsla(0, 0%, 100%, 0)"]
// or just loop thru below for succinctness
map.setPaintProperty( lowLayer, 'circle-color', circleColor ); map.setPaintProperty( midLayer, 'circle-color', circleColor ); map.setPaintProperty( highLayer, 'circle-color', circleColor );}

Within the updateLayer function, the positions of the tail and the head are updated, and this is achieved by calling Map.setPaintProperty with updated values. For this demo, I used the tail length of 5 but this can be elongated or shortened to change how the animation will look.

Map.setPaintProperty is an API in GL JS, and a compatible method exists in iOS (Style.updateLayer(withId:type:update:)) and Android (Style.setStyleLayerProperty). This means this implementation can be ported to other platforms fairly easily, as illustrated below.

This is an implementation using Android. The code is in this branch (commit).

Incorporating Wind Speed

The original dataset contains wind speed but this was not used in this demo. It could have been used

  1. to change the speed of the wind animation — This could be achieved by elongating the length of wind’s animatable points if the wind speed is higher.
  2. to change the wind color — The features in the dataset (before uploading to MTS) can contain `Feature.properties.windSpeed=<Double>` and then this can be styled on the client-side like
"circle-color": [  "case",  [    ">",    ["get", "windSpeed"],    10  ],  "red",  [    ">",    ["get", "windSpeed"],    ["literal", 5]  ],  "yellow",  "white"]

I challenge you to take my approach and add wind speed visualization!

Conclusion

As you can see, animating wind data is achievable and portable to all platforms including GL JS (Web), Android and iOS. There are more optimizations that should be done to deploy in production. For example, segmenting dataset into more layers to improve visualization at each zoom level, using symbol layer with an icon to improve the animation, or adding wind speed visualization. Now that I’ve shown how to resolve animation’s bottleneck, this opens up possibilities for all kinds of use cases!

--

--