Labeling bug with RangeDomain (4.26 and previous)

213
1
04-19-2023 01:30 PM
JoelBennett
MVP Regular Contributor

A bug in the JavaScript Maps SDK causes features to be labeled improperly when the field from which labels are derived has a range domain (i.e. RangeDomain).  Rather than the field's value being displayed, the field's domain name is displayed instead, as shown below:

range_dom.png

 

Displayed above is the Create a FeatureLayer with client-side graphics sample with some modifications that cause the issue to be seen.  This can be reproduced by replacing the contents of the third script tag with the code below (a simple diff utility can show the changes):

      require([
        "esri/Map",
        "esri/views/MapView",
        "esri/layers/FeatureLayer",
        "esri/layers/support/LabelClass",
        "esri/layers/support/RangeDomain",
        "esri/core/promiseUtils",
        "esri/Graphic",
        "esri/geometry/Point",
        "esri/rest/locator"
      ], (
        Map,
        MapView,
        FeatureLayer,
        LabelClass,
        RangeDomain,
        promiseUtils,
        Graphic,
        Point,
        locator
      ) => {
        var labelIndex = 1;
        const view = new MapView({
          map: new Map({
            basemap: "gray-vector"
          }),
          container: "viewDiv",
          extent: {
            spatialReference: {
              wkid: 102100
            },
            xmin: -14488954,
            ymin: 3457304,
            xmax: -10656095,
            ymax: 5250211
          },
          popup: {
            dockEnabled: true,
            dockOptions: {
              position: "top-right",
              breakpoint: false
            }
          }
        });

        view
          .when()
          .then(fetchImages)
          .then(getFeaturesFromPromises)
          .then(createLayer)
          .then(addToView)
          .catch((e) => {
            console.error("Creating FeatureLayer from photos failed", e);
          });

        /**
         * Fetches a list of images and returns a list of promises
         */
        function fetchImages() {
          const numPhotos = 18;
          const graphicPromises = [];
          const baseUrl =
            "https://arcgis.github.io/arcgis-samples-javascript/sample-data/featurelayer-collection/photo-";

          for (let i = 1; i <= numPhotos; i++) {
            const url = baseUrl + i.toString() + ".jpg";
            const graphicPromise = exifToGraphic(url, i);
            graphicPromises.push(graphicPromise);
          }

          return promiseUtils.eachAlways(graphicPromises);
        }

        // Filters only promises that resolve with valid values (a graphic
        // in this case) and resolves them as an array of graphics.
        // In other words, each attempt at fetching an image returns a promise.
        // Images that fail to fetch will be filtered out of the response array
        // so the images that successfully load can be added to the layer.
        function getFeaturesFromPromises(eachAlwaysResponses) {
          return eachAlwaysResponses
            .filter((graphicPromise) => {
              return graphicPromise.value;
            })
            .map((graphicPromise) => {
              return graphicPromise.value;
            });
        }

        //  Creates a client-side FeatureLayer from an array of graphics
        function createLayer(graphics) {
          return new FeatureLayer({
            source: graphics,
            objectIdField: "OBJECTID",
            fields: [
              {
                name: "OBJECTID",
                type: "oid"
              },
              {
                name: "url",
                type: "string"
              },
              {
                name: "labeltest",
                type: "integer",
                domain: RangeDomain.fromJSON({
                  type: "range",
                  name: "d_LABELTEST",
                  range: [1,10000000]
                })
              }
            ],
            labelingInfo: [
              LabelClass.fromJSON({
                "labelPlacement": "esriServerPolygonPlacementAlwaysHorizontal",
                "where": null,
                "labelExpression": "[labeltest]",
                "useCodedValues": true,
                "symbol": {
                  "type": "esriTS",
                  "color": [
                    230,
                    76,
                    0,
                    255
                  ],
                  "backgroundColor": null,
                  "borderLineColor": null,
                  "borderLineSize": null,
                  "verticalAlignment": "bottom",
                  "horizontalAlignment": "left",
                  "rightToLeft": false,
                  "angle": 0,
                  "xoffset": 0,
                  "yoffset": 0,
                  "kerning": true,
                  "haloColor": [
                    255,
                    255,
                    255,
                    178
                  ],
                  "haloSize": 1.25,
                  "font": {
                    "family": "Arial",
                    "size": 16,
                    "style": "normal",
                    "weight": "bold",
                    "decoration": "none"
                  }
                },
                "minScale": 0,
                "maxScale": 0
              })
            ],
            popupTemplate: {
              title: (event) => {
                return locator
                  .locationToAddress({
                    location: event.graphic.geometry
                  })
                  .then((response) => {
                    return response.address;
                  })
                  .catch((error) => {
                    return "The middle of nowhere";
                  });
              },
              content: "<img src='{url}'>"
            },
            renderer: {
              type: "simple",
              symbol: {
                type: "text",
                color: "#7A003C",
                text: "\ue661",
                font: {
                  size: 20,
                  family: "CalciteWebCoreIcons"
                }
              }
            }
          });
        }

        // Adds a given layer to the map in the view
        function addToView(layer) {
          view.map.add(layer);
        }

        /**
         * Fetches and loads an image from a url and gets the latitude/longitude
         * GPS data from the EXIF data of the image. Returns a promise that
         * resolves to a Graphic with a point geometry representing the location
         * where the photo was taken.
         */
        function exifToGraphic(url, id) {
          return promiseUtils.create((resolve, reject) => {
            const image = document.createElement("img");
            image.src=url;
            image.onload = () => {
              image.load = image.onerror = null;
              EXIF.getData(image, function () {
                const latitude = EXIF.getTag(this, "GPSLatitude");
                const latitudeDirection = EXIF.getTag(this, "GPSLatitudeRef");
                const longitude = EXIF.getTag(this, "GPSLongitude");
                const longitudeDirection = EXIF.getTag(this, "GPSLongitudeRef");

                if (!latitude || !longitude) {
                  reject(
                    new Error(
                      "Photo doesn't contain GPS information: ",
                      this.src
                    )
                  );
                  return;
                }

                const location = new Point({
                  latitude: dmsDD(latitude, latitudeDirection),
                  longitude: dmsDD(longitude, longitudeDirection)
                });

                resolve(
                  new Graphic({
                    geometry: location,
                    attributes: {
                      url: url,
                      OBJECTID: id,
                      labeltest: labelIndex++
                    }
                  })
                );
              });
            };

            image.onerror = () => {
              image.load = image.onerror = null;
              reject(new Error("Error while loading the image"));
            };
          });
        }

        // Converts a DMS coordinate to decimal degrees
        function dmsDD([degrees, minutes, seconds], direction) {
          let dd = degrees + minutes / 60 + seconds / 3600;
          if (direction === "S" || direction === "W") {
            dd *= -1;
          }
          return dd;
        }
      });

 

The problem occurs in the esri/layers/support/labelFormatUtils module - in particular, line 15 of the function below:

    function t(b, f) {
        if (null == b)
            return "";
        const a = f.domain;
        if (a)
            if ("codedValue" === a.type || "coded-value" === a.type)
                for (var g of a.codedValues) {
                    if (g.code === b)
                        return g.name
                }
            else if ("range" === a.type) {
                g = +b;
                const h = "range"in a ? a.range[1] : a.maxValue;
                if (("range"in a ? a.range[0] : a.minValue) <= g && g <= h)
                    return a.name
            }
        "date" === f.type || "esriFieldTypeDate" === f.type ? b = q.formatDate(b, q.convertDateFormatToIntlOptions("short-date")) : z.isNumericField(f) && (b = y.formatNumber(+b));
        return b ? b : ""
    }

 

As can be seen, if the value is within the range of the domain, the domain's name is returned.  This issue can be fixed by replacing "return a.name" with "return b".  The code shown above is from version 4.26, but the problem goes at least as far back as 4.23.

1 Reply
JoelBennett
MVP Regular Contributor

Updated code and information below to correspond with recent changes in the SDK.

Revised demonstration code since promiseUtils.create was removed in 4.27:

      require([
        "esri/Map",
        "esri/views/MapView",
        "esri/layers/FeatureLayer",
        "esri/layers/support/LabelClass",
        "esri/layers/support/RangeDomain",
        "esri/core/promiseUtils",
        "esri/Graphic",
        "esri/geometry/Point",
        "esri/rest/locator"
      ], (
        Map,
        MapView,
        FeatureLayer,
        LabelClass,
        RangeDomain,
        promiseUtils,
        Graphic,
        Point,
        locator
      ) => {
        var labelIndex = 1;
        const view = new MapView({
          map: new Map({
            basemap: "gray-vector"
          }),
          container: "viewDiv",
          extent: {
            spatialReference: {
              wkid: 102100
            },
            xmin: -14488954,
            ymin: 3457304,
            xmax: -10656095,
            ymax: 5250211
          },
          popup: {
            dockEnabled: true,
            dockOptions: {
              position: "top-right",
              breakpoint: false
            }
          }
        });

        view
          .when()
          .then(fetchImages)
          .then(getFeaturesFromPromises)
          .then(createLayer)
          .then(addToView)
          .catch((e) => {
            console.error("Creating FeatureLayer from photos failed", e);
          });

        /**
         * Fetches a list of images and returns a list of promises
         */
        function fetchImages() {
          const numPhotos = 18;
          const graphicPromises = [];
          const baseUrl =
            "https://arcgis.github.io/arcgis-samples-javascript/sample-data/featurelayer-collection/photo-";

          for (let i = 1; i <= numPhotos; i++) {
            const url = baseUrl + i.toString() + ".jpg";
            const graphicPromise = exifToGraphic(url, i);
            graphicPromises.push(graphicPromise);
          }

          return promiseUtils.eachAlways(graphicPromises);
        }

        // Filters only promises that resolve with valid values (a graphic
        // in this case) and resolves them as an array of graphics.
        // In other words, each attempt at fetching an image returns a promise.
        // Images that fail to fetch will be filtered out of the response array
        // so the images that successfully load can be added to the layer.
        function getFeaturesFromPromises(eachAlwaysResponses) {
          return eachAlwaysResponses
            .filter((graphicPromise) => {
              return graphicPromise.value;
            })
            .map((graphicPromise) => {
              return graphicPromise.value;
            });
        }

        //  Creates a client-side FeatureLayer from an array of graphics
        function createLayer(graphics) {
          return new FeatureLayer({
            source: graphics,
            objectIdField: "OBJECTID",
            fields: [
              {
                name: "OBJECTID",
                type: "oid"
              },
              {
                name: "url",
                type: "string"
              },
              {
                name: "labeltest",
                type: "integer",
                domain: RangeDomain.fromJSON({
                  type: "range",
                  name: "d_LABELTEST",
                  range: [1,10000000]
                })
              }
            ],
            labelingInfo: [
              LabelClass.fromJSON({
                "labelPlacement": "esriServerPolygonPlacementAlwaysHorizontal",
                "where": null,
                "labelExpression": "[labeltest]",
                "useCodedValues": true,
                "symbol": {
                  "type": "esriTS",
                  "color": [
                    230,
                    76,
                    0,
                    255
                  ],
                  "backgroundColor": null,
                  "borderLineColor": null,
                  "borderLineSize": null,
                  "verticalAlignment": "bottom",
                  "horizontalAlignment": "left",
                  "rightToLeft": false,
                  "angle": 0,
                  "xoffset": 0,
                  "yoffset": 0,
                  "kerning": true,
                  "haloColor": [
                    255,
                    255,
                    255,
                    178
                  ],
                  "haloSize": 1.25,
                  "font": {
                    "family": "Arial",
                    "size": 16,
                    "style": "normal",
                    "weight": "bold",
                    "decoration": "none"
                  }
                },
                "minScale": 0,
                "maxScale": 0
              })
            ],
            popupTemplate: {
              title: (event) => {
                return locator
                  .locationToAddress({
                    location: event.graphic.geometry
                  })
                  .then((response) => {
                    return response.address;
                  })
                  .catch((error) => {
                    return "The middle of nowhere";
                  });
              },
              content: "<img src='{url}'>"
            },
            renderer: {
              type: "simple",
              symbol: {
                type: "text",
                color: "#7A003C",
                text: "\ue661",
                font: {
                  size: 20,
                  family: "CalciteWebCoreIcons"
                }
              }
            }
          });
        }

        // Adds a given layer to the map in the view
        function addToView(layer) {
          view.map.add(layer);
        }

        /**
         * Fetches and loads an image from a url and gets the latitude/longitude
         * GPS data from the EXIF data of the image. Returns a promise that
         * resolves to a Graphic with a point geometry representing the location
         * where the photo was taken.
         */
        function exifToGraphic(url, id) {
          return new Promise((resolve, reject) => {
            const image = document.createElement("img");
            image.src=url;
            image.onload = () => {
              image.load = image.onerror = null;
              EXIF.getData(image, function () {
                const latitude = EXIF.getTag(this, "GPSLatitude");
                const latitudeDirection = EXIF.getTag(this, "GPSLatitudeRef");
                const longitude = EXIF.getTag(this, "GPSLongitude");
                const longitudeDirection = EXIF.getTag(this, "GPSLongitudeRef");

                if (!latitude || !longitude) {
                  reject(
                    new Error(
                      "Photo doesn't contain GPS information: ",
                      this.src
                    )
                  );
                  return;
                }

                const location = new Point({
                  latitude: dmsDD(latitude, latitudeDirection),
                  longitude: dmsDD(longitude, longitudeDirection)
                });

                resolve(
                  new Graphic({
                    geometry: location,
                    attributes: {
                      url: url,
                      OBJECTID: id,
                      labeltest: labelIndex++
                    }
                  })
                );
              });
            };

            image.onerror = () => {
              image.load = image.onerror = null;
              reject(new Error("Error while loading the image"));
            };
          });
        }

        // Converts a DMS coordinate to decimal degrees
        function dmsDD([degrees, minutes, seconds], direction) {
          let dd = degrees + minutes / 60 + seconds / 3600;
          if (direction === "S" || direction === "W") {
            dd *= -1;
          }
          return dd;
        }
      });

 

Despite changes being made to the offending function within labelFormatUtils.js in 4.28, the bug still remains.  The problem occurs on line 15 below; instead of "return g.name" it should be "return a".

    function v(a, e) {
        if (null == a)
            return "";
        const g = e.domain;
        if (g)
            if ("codedValue" === g.type || "coded-value" === g.type)
                for (var f of g.codedValues) {
                    if (f.code === a)
                        return f.name
                }
            else if ("range" === g.type) {
                const {max: h, min: b} = z.getDomainRange(e);
                f = +a;
                if (null != b && null != h && b <= f && f <= h)
                    return g.name
            }
        t.isDateField(e) ? a = r.formatDate(a, r.convertDateFormatToIntlOptions("short-date")) : t.isNumericField(e) && (a = y.formatNumber(+a));
        return a || ""
    }

 

0 Kudos