ArcGIS Maps SDK for JavaScript

Visualizing Air Quality with Animated Symbols

Air quality is an essential aspect of our daily lives and significantly impacts our health and overall well-being. As we approach wildfire season in California and witness rising carbon emissions across the globe, being aware of the air quality in our surroundings becomes even more important.

There’s no shortage of maps visualizing air quality, but in this blog I’ll discuss how you can use animated symbols to draw attention to areas with unhealthy or hazardous air quality. To do this, I’ll take advantage of the animation capabilities in CIM symbols in the ArcGIS Maps SDK for JavaScript.

View the app | Source code

Air Quality Index in the Los Angeles area on July 31st around 10am.

What’s a CIM symbol?

CIM symbols are complex, multi-layer vector symbols that can be used to visualize point, line, and polygon features. They can be comprised of one or more symbol layers with a variety of properties, giving you extensive control over how your symbol is visualized. With CIM symbols, you can create unique, custom symbols that fit your needs.

Another aspect of working with CIM symbols is that you can use primitive overrides, which allow you to dynamically update the property of a symbol layer (primitive) based on a feature attribute or Arcade expression.

CIM symbols are created based on a JSON object of a CIMSymbolReference that follows the cim specification, which is used to define symbology across ArcGIS products.

For more information on CIM symbols, you can check out this blog or this presentation from the Developer Summit.

CIM symbols + animation

You can add animation to your CIM symbols with animated GIFs or PNGs in a CIMPictureMarker symbol layer. At version 4.24, we added support for a variety of animation properties that give you more control over how your GIF will animate, such as the duration, startTime, repeatType, and more.

You can also use primitive overrides with any of these properties, tying them to a feature attribute or Arcade expression. For instance, I want features with high air quality to animate faster than those with lower air quality. To do this, I’ll set a primitive override on the duration property, returning a shorter duration when the air quality is high, and a longer duration when air quality is low.

Air Quality Index in Riverside county during the Bonny Fire at 8am on Monday, July 31st. Notice how features with the highest AQI (in maroon) animate faster than those with a lower AQI (in red and purple).

Now that you have a bit of background information, let’s get into how the app was built.

How the app was built

Getting the data

The first step when creating a mapping application is always finding the right data, and the first place I like to look for data is the Living Atlas. The Living Atlas contains a variety of layers, maps, and applications about air quality. In this app, I’ll use the OpenAQ Recent Conditions in Air Quality layer, which is a live feed containing air quality information from OpenAQ.

Creating the CIM symbol

Once we have the data, the next step is to symbolize it in the map – I’ll do this using a CIM symbol. As I mentioned earlier, CIM symbols are created from a JSON object based on the CIMSymbolReference. This JSON can get quite large and can be cumbersome to write from scratch, so that’s where the CIM Symbol Builder can help. It is a tool that provides a simple UI for creating CIM symbols, allowing you to add symbol layers, adjust their properties, visualize the symbol in a map, and then copy out the JSON to use when you’re done. For more info on the CIM symbol builder, check out this blog post.

Using the CIM Symbol Builder, I first added a circle CIMVectorMarker symbol layer (found under the shapes tab), and then I added a CIMPictureMarker symbol layer (found under the Custom tab). I replaced the URL to the image to a GIF from Wikimedia commons of a blinking white circle, as shown in the illustration below.

The CIM Symbol Builder can help to quickly create CIM symbols by providing a UI to add symbol layers and update their properties.

After playing around with the colors and sizes of each symbol layer, I clicked the “Get Symbol JSON” button to copy out the symbol JSON and use it in my app.

I placed the symbol JSON in a function called createCIMSymbol(). I updated the CIM JSON by adding the animatedSymbolProperties to my CIMPictureMarker symbol layer. This allowed me to set the animation to “Loop” and reduce the length of the animation.


{
  type: "CIMPictureMarker",
  enable: true,
  size: 25,
  scaleX: 1,
  tintColor: [255, 255, 255, 255],
  url: "https://upload.wikimedia.org/wikipedia/commons/f/fb/Cercle_white_blink.gif",
  animatedSymbolProperties: {
    playAnimation: true,
    duration: 0.75,
    repeatType: "Loop"
  }
}

Since we’ll use the createCIMSymbol function to create multiple CIM symbols of different colors, we’ll pass in color r, b, g as a function argument, and update all instances of color in the symbol to use those values.

function createCIMSymbol(r, g, b) {
  return new CIMSymbol({
    data: {
      type: "CIMSymbolReference",
      // primitiveOverrides: [],
      symbol: {
        type: "CIMPointSymbol",
        symbolLayers: [{
          type: "CIMPictureMarker",
          enable: true,
          size: 25,
          scaleX: 1,
          tintColor: [r, g, b, 255],
          url: "https://upload.wikimedia.org/wikipedia/commons/f/fb/Cercle_white_blink.gif",
          animatedSymbolProperties: {
            playAnimation: true,
            duration: 0.75,
            repeatType: "Loop"
          }
        },
        {
          type: "CIMVectorMarker",
          enable: true,
          anchorPoint: {x: 0, y: 0},
          anchorPointUnits: "Relative",
          size: 22,
          frame: {xmin: 0, ymin: 0, xmax: 17, ymax: 17},
          markerGraphics: [{
            type: "CIMMarkerGraphic",
            geometry: {...},
            symbol: {
              type: "CIMPolygonSymbol",
              symbolLayers: [{
                type: "CIMSolidFill",
                enable: true,
                color: [r, g, b, 125]
              }]
            }
          }],
          scaleSymbolsProportionally: true,
          respectFrame: true
        }]
      }
    }
  });
}

We’ll use this symbol with a ClassBreaksRenderer on our layer to separate the air quality into 6 categories based on the Level of Concern from the EPA – Good, Moderate, Unhealthy for Sensitive Groups, Unhealthy, Very Unhealthy, and Hazardous.

AQI colors, levels of concern and index values from the EPA.

For each of these categories, we’ll use the same symbol but with a unique color, based on the AQI level of concern.

layer.renderer = {
  type: "class-breaks",
  field: "OZONEPM_AQI_SORT",
  legendOptions: {
    title: "Ozone & PM AQI"
  },
  classBreakInfos: [
    {
      minValue: 301,
      maxValue: 5000,
      symbol: createCIMSymbol(126, 0, 35),
      label: "Hazardous"
    },
    {
      minValue: 201,
      maxValue: 300,
      symbol: createCIMSymbol(143, 63, 151),
      label: "Very Unhealthy"
    },
    // continue with the rest of the AQI categories
    {...}
  ]
};

 

Making it data driven

Now that we’ve created our symbol, it’s time to add some data behind it. Lots of flashing circles doesn’t make for a great map, so let’s start by only playing the symbol’s animation when the air quality of a feature is “Unhealthy”, “Very Unhealthy”, or “Hazardous”.

We’ll add a primitive override on our symbol for the playAnimation property. We want this property to be true if the air quality is “Unhealthy” or worse, otherwise, we want this property to be false. To create a primitive override for our symbol, we first need to give it a primitiveName – we’ll call this “animationOverride” – and determine the property that we want to override – PlayAnimation. Then we need to apply this same primitiveName within the object of the property we want to override.

function createCIMSymbol(r, g, b) {
  return new CIMSymbol({
    data: {
      type: "CIMSymbolReference",
      primitiveOverrides: [{
        type: "CIMPrimitiveOverride",
        primitiveName: "animationOverride",
        propertyName: "PlayAnimation",
        ...
      }],
      symbol: {
        type: "CIMPointSymbol",
        symbolLayers: [
          {
            type: "CIMPictureMarker",
            enable: true,
            size: 25,
            scaleX: 1,
            tintColor: [r, g, b, 255],
            url: "https://upload.wikimedia.org/wikipedia/commons/f/fb/Cercle_white_blink.gif",
            animatedSymbolProperties: {
              primitiveName: "animationOverride",
              playAnimation: true,
              duration: 0.75,
              repeatType: "Loop"
            }
          },
          { ... }
        ]
      }
    }
  });
}

Next, we’ll create an Arcade expression from the Arcade visualization profile that will determine the value of playAnimation for each feature. We’ll use an Arcade comparison operator to determine if the AQI of a feature is greater than or equal to 151, then it’s unhealthy and we want the symbol to animate (so the expression will return true). Otherwise, the expression will  return false.

primitiveOverrides: [{
  type: "CIMPrimitiveOverride",
  primitiveName: "animationOverride",
  propertyName: "PlayAnimation",
  valueExpressionInfo: {
    type: "CIMExpressionInfo",
    expression: `$feature.OZONEPM_AQI_SORT >= 151`
  }
}]

Now we should only see animations where the air quality is Unhealthy, Very Unhealthy, or Hazardous.

Let’s add a little more meaning to the animation by updating the duration of each animation based on the hazard level. The more hazardous the air quality, the faster we want the animation to oscillate. We can do this by adding another primitive override on the Duration property. We can use the same primitive name because the property we want to override is still within that animatedSymbolProperties object on our CIMPictureMarker symbol layer.

Using Arcade’s When function, we can calculate when the AQI is considered “Unhealthy”, “Very Unhealthy”, and “Hazardous”, and update the duration of the animation to 1, 0.5, and 0.35 seconds, respectively. This will cause the animation to blink much faster at “Hazardous” levels than “Unhealthy” levels.

primitiveOverrides: [{
    type: "CIMPrimitiveOverride",
    primitiveName: "animationOverride",
    propertyName: "PlayAnimation",
    valueExpressionInfo: {
      type: "CIMExpressionInfo",
      expression: `IIF($feature.OZONEPM_AQI_SORT >= 151, true, false)`
    }
  },{
    type: "CIMPrimitiveOverride",
    primitiveName: "animationOverride",
    propertyName: "Duration",
    valueExpressionInfo: {
      type: "CIMExpressionInfo",
      expression: `When(
        $feature.OZONEPM_AQI_SORT >= 151 && $feature.OZONEPM_AQI_SORT < 200, 1,
        $feature.OZONEPM_AQI_SORT >= 201 && $feature.OZONEPM_AQI_SORT < 300, 0.5,
        0.35
      )`
    }
}]

View the app to see this animation in action and click the Find worst AQI button in the top right to quickly view the feature with the highest AQI in the map. You can find the source code for the app here.

Conclusion

In conclusion, animated CIM symbols can help users quickly see where the air quality is Unhealthy or Hazardous. Using animations in your maps can help bring your data to life and provide additional context for users at a quick glance.

On the ArcGIS Maps SDK for JavaScript team, we are working on adding additional symbol animation capabilities, but would love to hear from you! What type of animations do you want to see supported? Let us know about any use cases you might have — feel free to email me or post in the comments below!

About the author

Anne Fitz is a Senior Product Engineer at Esri, working on the ArcGIS Maps SDK for JavaScript and ArcGIS Arcade. Her expertise primarily focuses on dynamic vector symbology (CIM symbols), data visualization, animation, ArcGIS Arcade integration, and widgets. Anne’s primary goal is to help developers succeed in building geospatial applications, providing them with the tools and knowledge they need to bring their ideas to life. She has been with Esri for over four years.

Connect:
0 Comments
Inline Feedbacks
View all comments

Next Article

Indoor GIS at the 2024 Esri Partner Conference and Developer Summit

Read this article