Software development

Implementation of feature highlights in the ArcGIS Maps SDK for JavaScript

Highlight is an important part of interactive applications. Join us for this deep-dive into the programming techniques we use for implementing this feature in our products.

This article comes with a code sample on CodePen. Be sure to check it out!

Table of Contents

  • Introduction
  • How highlights fit in the map rendering process
  • Implementing highlights: a high-level view
  • Build the binary mask
  • Compute the signed distance field (SDF)
  • Render and composite the highlight
  • Possible extensions and improvements
  • Conclusions

Introduction

Interactive applications enable the user to select, inspect, and modify their content. In GIS, content usually comes in the form of geographical entities called “features”, that are visualized as markers, fills and lines, on top of a basemap.

More often than not, there are multiple features on the screen, but the user interacts with only a subset of them at a time. Here are some examples of ways that a user can interact with the displayed features. In all these cases, the technique of “highlighting” the feature or features involved can improve the user experience.

This article details a simplified version of the technique that we use to implement highlights in the ArcGIS Maps SDK for JavaScript. Knowledge of WebGL and GLSL shaders is recommended but not essential to understand the principle of operation of the technique. The code sample includes 3 vertex shaders and 3 fragment shaders, but in this article we only discuss the fragment shaders because the required vertex processing is fairly generic and carries no highlight-specific concepts.

How highlights fit in the map rendering process

In the ArcGIS Maps SDK for JavaScript, highlights are colored overlays that are added on top of the basemap and operational layers. They are characterized by a fill color and an outline color.

Here at Esri we are in the process of implementing multiple configurable highlight “groups”, that are applied to different features depending on the situation, based on a priority scheme. Highlights from the same group merge together, while highlights from higher priority groups override highlights from lower priority groups.

In the ArcGIS Maps SDK for JavaScript, highlights are implemented using a multi-step image processing technique powered by WebGL and GLSL shaders; the entire highlight workflow runs after the basemap and the operational layer have been drawn.

function renderMap(): void {
  renderBasemap();
  renderFeatures();
 renderHighlights();
}

We are going to take a look at how the renderFeatures() function is implemented, because rendering highlights require re-rendering the same features using different parameters and WebGL state. The defining traits of the feature rendering step are listed below.

  • It composites features with the basemap using standard additive blending.
  • It uses a WebGL shader program dedicated to feature rendering.
  • It renders all features, in their original colors. The rendering style is controlled by setting the uniform flag u_OutputBinary to 0.
function renderFeatures(): void {
  gl.blendEquation(gl.FUNC_ADD);
  gl.useProgram(featureProgram);
  gl.uniform1i(u_OutputBinary, 0);
  for (const feature of features) {
    renderFeature(feature);
  }
}

Implementation note. Since this article is about the highlight technique, for the sake of simplicity we assume that we have a renderFeature() function that renders a given feature. Please note that in the actual ArcGIS Maps SDK for JavaScript implementation we never render individual features, because it leads to CPU-bound code that does not scale with the number of features. Instead, we batch multiple features together and render them with a single draw call.

Here is what the features look like, prior to the application of the highlights. In the rest of the article, we are going to assume that we want to highlight all land vehicles in cyan, all vessels in yellow, and all aircrafts in magenta.

The features, without highlights applied yet.

The shader program that renders the features has very loose requirements; anything that draws something on screen will work; even 3D shapes under perspective projections. The only requirement is that there must be a uniform u_OutputBinary that when set to 1 forces the fragment shader to output solid white. This functionality will be used to build the “binary mask” that is the input for the image processing algorithm.

In the CodePen sample, we simply render markers using texture-mapped quads; the u_OutputBinary uniform forces the sampled color to become solid white, but only if it’s opaque enough; this causes only the pixels of the represented object to turn on as white, and not the entire quad.

#version 300 es
precision mediump float;
 
in vec2 v_Texcoord;
 
uniform sampler2D u_ColorTexture;
uniform bool u_OutputBinary;
 
layout(location = 0) out vec4 o_Color;
 
void main(void) {
 o_Color = texture(u_ColorTexture, v_Texcoord);
 
 if (u_OutputBinary) {
 if (o_Color.a > 0.1) {
 o_Color = vec4(1.0);
 } else {
 o_Color = vec4(0.0);
 }
 }
}

Implementing highlights: a high-level view

Rendering a single-colored highlight is a 3-step process.

  1. Build the binary mask.
  2. Compute the signed distance field (SDF).
  3. Render the highlight, compositing it on top of the operational layer.

This 3-step process must itself be repeated with slightly different parameters for every highlight group. For instance, let us assume that the following highlight groups needs to be supported.

  • Cyan, with low priority.
  • Yellow, with intermediate priority;
  • Magenta, with high priority.

Then, the whole process needs to be repeated 3 times. The diagram below shows how the final image is incrementally built, step by step. In total, a case like the one above would require 12 distinct drawing operations.

The renderHighlights() function implements all the highlight-related logic.

function renderHighlights(): void {
 for (let i = 0; i < highlightGroups.length; i++) {
 buildBinaryMask(i);
 computeSDF();
 renderHighlight(i);
 }
}

Let us dive into each of the required steps.

Build the binary mask

The binary mask is a raster image that is 0 where there are no features to highlight, and 1 where there are. It is produced by an iterative process that renders the features using a modified code path that outputs binary values. Building the binary mask for the group with index highlightGroupIndex is a 3-step process that runs after the highlights of all features from lower-priority groups 0, 1, ..., highlightGroupIndex - 1 have already been rendered and composited.

  • Clear a dedicated framebuffer to 0.
  • Render the features in the group highlightGroupIndex as 1s. This can be done using the same shader program as when rendering features but setting u_OutputBinary to 1 so that the fragment shader outputs solid white instead of the original color.
  • Clear all the features in any group with index greater than highlightGroupIndex to 0s. This uses the same configuration of the previous step, but with a modified blending equation.
function buildBinaryMask(highlightGroupIndex: number): void {
 gl.bindFramebuffer(gl.FRAMEBUFFER, binaryFramebuffer);
 gl.clearColor(0, 0, 0, 0);
 gl.clear(gl.COLOR_BUFFER_BIT);

 setBinaryMask(highlightGroupIndex);
 clearBinaryMask(highlightGroupIndex);
}

The setBinaryMask() is very similar to renderFeatures(); the only differences are that it renders only the features that are highlighted in the current group, and sets u_OutputBinary to 1, to get a binary output.

function setBinaryMask(highlightGroupIndex: number): void {
 gl.blendEquation(gl.FUNC_ADD);
 gl.useProgram(featureProgram);
 gl.uniform1i(u_OutputBinary, 1);
 for (const feature of features) {
 if (getHighlightGroupIndex(feature) === highlightGroupIndex)
 renderFeature(feature);
 }
 }
}

As an example, here is what the binary mask would look like after running setBinaryMask() on a group that contains all land vehicles, like cars and motorbikes.

All land vehicles are rendered as solid white, against a transparent background.

The clearBinaryMask() is very similar to setBinaryMask(); the only differences are that it renders only the features that will need to be highlighted in higher priority groups, and that it uses the blend equation gl.FUNC_REVERSE_SUBTRACT to “erase” the 1s and replacing them with 0s; this is to make room for higher priority highlights that will be rendered later.

function clearBinaryMask(highlightIndex: number): void {
 gl.blendEquation(gl.FUNC_REVERSE_SUBTRACT);
 gl.useProgram(featureProgram);
 gl.uniform1i(u_OutputBinary, 1);
 for (const feature of features) {
 if (getHighlightIndex(feature) > highlightIndex)
 renderFeature(feature);
 }
 }
}

In our example above, we would use the clearBinaryMask() function to remove from the binary mask the areas that are occupied by vessels or aircrafts, so that only the pixels for land vehicles are left.

All vessels and aircrafts are cleared from the mask, leaving only parts of some of the previously rendered land vehicles.

Compute the signed distance field (SDF)

This is an image processing step that takes as input the binary mask and outputs another raster where each pixel contains the distance between the original pixel and the closest pixel of opposite logical value. It uses a dedicated shader program whose vertex shader sets up a full-screen quad, and whose fragment shader explores a 21x21 neighborhood, that is, four 10x10 quadrants, around each pixel of the binary mask, and outputs a signed distance value. The value gets more negative as we move away from 1 into 0 (that is, away from the feature), and gets more positive as we move away from 0 into 1 (that is, into the feature).

A neighborhood of pixels in the binary mask and its corresponding signed distance field (SDF).

The search for an opposite logical value is implemented by initializing a distance value to 15, which is the maximum distance across the diagonal of a 10x10 quadrant, and scans all pixels, in all quadrants, until it finds one of opposite logical value; when that happens, distance gets updated.

After the search has completed, distance receives its conventional sign.

  • Positive for neighborhoods centered on a 1, with a 0 nearby.
  • Negative for neighborhoods centered on a 0, with a 1 nearby.

Pixels that are far away from any pixel with opposite logical value will remain stuck at +15 or -15.

As a final step, the computed distance is scaled and biased so that it can be stored in the red channel of RGBA8 texture; this last step is not required if using floating-point textures.

#version 300 es
precision mediump float;
 
in vec2 v_Texcoord;
 
uniform vec2 u_ViewSize;
uniform sampler2D u_ColorTexture;
 
layout(location = 0) out vec4 o_Color;
 
void main(void) {
 float distance = 15.0;
 float center = texture(u_ColorTexture, v_Texcoord).a;

 for (int i = -10; i <= 10; i++) {
 for (int j = -10; j <= 10; j++) {
 float neighbor = texture(u_ColorTexture, v_Texcoord + vec2(float(j), float(i)) / u_ViewSize).a;
 float d = sqrt(float(i * i + j * j));
 if (center != neighbor && d < distance) {
 distance = d;
 }
 }
 }

 if (center < 0.5) {
 distance = -distance;
 }

 o_Color = vec4(0.5 + 0.5 * distance / 15.0, 0.0, 0.0, 1.0);
}

Implementation note. The code sample uses a very generous 21x21 exact SDF; this allows for signed distances in the range [-14, +14] to be computed and hence enables very thick outlines. In the implementation of highlights in the ArcGIS Maps SDK for JavaScript, we adopted smaller neighborhoods, and we used an approximate SDF estimation technique that is less accurate visually, but much more efficient.

Here is what the SDF for the previously built binary mask look like. Black is 0 and represents a distance of -15, while fully saturated red is 255 and represents a distance of +15.

The SDF for land vehicles.

Render and composite the highlight

Highlight rendering is another full-screen pass that takes the generated SDF and colorizes it based on the preferences of the user. The fragment shader recovers the distance value stored in the red channel of the RGBA8 texture, undoes scaling and bias (this step is not required if the distance was stored in a floating-point texture), and checks the distance value against the outline width specified by the user.

  • If found to be less than -u_OutlineWidth / 2.0 then the fragment is far enough outside of the feature that it should be rendered as transparent.
  • If found to be between -u_OutlineWidth / 2.0 and u_OutlineWidth / 2.0 then the fragment is on the outline of the highlight and should be rendered with a certain user-defined opacity.
  • Otherwise, the fragment is part of the fill and should be rendered with another user-defined opacity.
#version 300 es
precision mediump float;
 
in vec2 v_Texcoord;
 
uniform vec2 u_ViewSize;
uniform sampler2D u_DistanceTexture;
uniform vec4 u_HighlightColor;
uniform float u_OutlineWidth;
uniform float u_FillOpacity;
uniform float u_OutlineOpacity;
 
layout(location = 0) out vec4 o_Color;
 
void main(void) {
 float distance = texture(u_DistanceTexture, v_Texcoord).r;
 distance -= 0.5;
 distance /= 0.5;
 distance *= 15.0;

 float r;

 if (distance > u_OutlineWidth / 2.0) {
 r = u_FillOpacity;
 } else if (distance < -u_OutlineWidth / 2.0) {
 r = 0.0;
 } else {
 r = u_OutlineOpacity;
 }

 o_Color = u_HighlightColor;
 o_Color.a *= r;
 o_Color.rgb *= o_Color.a;
}

The code above colorizes an SDF using cyan and renders it on top of the image.

The colorized SDF coincides with the areas of the screen occupied by land vehicles.

Possible extensions and improvements

We tried to keep the complexity of the code in this article and in the CodePen sample to a minimum; with that said, there are a lot of improvements and optimizations that we are either already shipping in the ArcGIS Maps SDK for JavaScript, or that we are considering implementing in the future.

  • Approximate efficient SDF computation based on separable gaussian kernels.
  • Different colors for the fill of a highlight and its outline.
  • Anti-aliased outlines.
  • Pad the outlines outward so that feature edges are not covered.
  • Soft outlines, halos, and “glow” effect.
  • Animating highlight colors.
  • Animating outline size.

Conclusions

Simple and efficient highlights can be implemented using image processing techniques and some kind of distance field, either exact or approximate. Implementation is easy on WebGL-enabled platforms and can be ported to any other language or graphic APIs. Check out the sample and experiment with it, and tell us what your experience is. Also don’t forget to follow EsriDevs on X!

Here at Esri we love web graphics and front-end technologies in general. If you love them too, please take a moment to visit our job openings for JavaScript and the Web platform.

Happy coding!

Next Article

Positions of GPS Satellites in 3D

Read this article