Source: src/js/views/maps/mapEditor/MapImageryEditorView.js

"use strict";

define(["backbone", "models/maps/assets/CesiumImagery"], (
  Backbone,
  CesiumImagery,
) => {
  const CLASS_NAMES = {
    BASE: "map-imagery-editor",
    FIELD: "map-asset-editor__field",
    SOURCE: "map-asset-editor__source",
    EXTENT: "map-asset-editor__extent",
    ERROR: "map-asset-editor__error",
  };
  const { PROVIDER_TYPES } = CesiumImagery;
  const TILING_SCHEMES = {
    GEOGRAPHIC: "GeographicTilingScheme",
    WEB_MERCATOR: "WebMercatorTilingScheme",
  };
  const ATTRIBUTES = {
    PROVIDER: "data-provider",
    ERROR: "data-error",
  };
  const ID_SUFFIXES = {
    WMTS_HELP: "wmts-help",
    WMTS_ERROR: "wmts-error",
    EXTENT_ERROR: "extent-error",
    ION_ERROR: "ion-error",
    OSM_ERROR: "osm-error",
    WMS_HELP: "wms-help",
    WMS_ERROR: "wms-error",
    TMS_ERROR: "tms-error",
    WMS_LAYERS_HELP: "wms-layers-help",
    WMS_LAYERS_ERROR: "wms-layers-error",
    BING_ERROR: "bing-error",
    BING_KEY_HELP: "bing-key-help",
  };
  const LABELS = {
    PROVIDER: "Imagery provider",
    WMTS: "Web Map Tile Service (WMTS)",
    WMS: "Web Map Service (WMS)",
    TMS: "Tile Map Service (TMS)",
    ION: "Cesium Ion",
    OSM: "OpenStreetMap",
    BING: "Bing Maps",
    NATURAL_EARTH: "Natural Earth II",
    USGS: "USGS Imagery Topo",
    TILE_URL: "Tile URL",
    TILING_SCHEME: "Tiling scheme",
    PROVIDER_DEFAULT: "Provider default",
    GEOGRAPHIC: "Geographic",
    WEB_MERCATOR: "Web Mercator",
    EXTENT: "Geographic extent (optional)",
    WEST: "West longitude (degrees)",
    SOUTH: "South latitude (degrees)",
    EAST: "East longitude (degrees)",
    NORTH: "North latitude (degrees)",
    ION_ASSET_ID: "Ion asset ID",
    TILE_SERVER_URL: "Tile server URL",
    SERVICE_URL: "Service URL",
    TILE_SERVICE_URL: "Tile service URL",
    LAYERS: "Layer names",
    BING_KEY: "Bing Maps API key (optional)",
  };
  const HELP_TEXT = {
    WMTS_URL:
      "Tile URL example: https://example.com/wmts/{TileMatrix}/{TileCol}/{TileRow}.png. Include all three placeholders.",
    WMS_URL:
      "Service URL example: https://example.com/geoserver/wms. Enter the layer names below.",
    WMS_LAYERS:
      "Use the layer names provided by the WMS service. Separate multiple names with commas.",
    BING_KEY:
      "Leave blank to use this site's Bing Maps API key, if configured.",
    NATURAL_EARTH:
      "Displays a world basemap with shaded terrain. No source settings are needed.",
    USGS: "Displays USGS aerial imagery with topographic map details. No source settings are needed.",
  };
  const GENERAL_ERROR_KEY = "general";

  /**
   * @class MapImageryEditorView
   * @classdesc Edits source settings for supported Cesium imagery providers.
   * @classcategory Views/Maps/MapEditor
   * @augments Backbone.View
   * @screenshot views/maps/mapEditor/MapImageryEditorView.png
   * @since 2.41.0
   */
  const MapImageryEditorView = Backbone.View.extend(
    /** @lends MapImageryEditorView.prototype */ {
      /**
       * The existing imagery model supplied by the caller.
       * @type {CesiumImagery}
       */
      model: null,

      /** @inheritdoc */
      className: CLASS_NAMES.BASE,

      /** @inheritdoc */
      events() {
        return {
          'change [name="type"]': "changeProvider",
          "change input, textarea, select": "updateModel",
          [`focusout .${CLASS_NAMES.EXTENT}`]: "validateExtent",
        };
      },

      /**
       * Build native controls for the supported imagery providers.
       * @returns {string} Markup containing no values from the model
       */
      template() {
        const fieldClass = CLASS_NAMES.FIELD;
        const sourceClass = CLASS_NAMES.SOURCE;
        const extentClass = CLASS_NAMES.EXTENT;
        const errorClass = CLASS_NAMES.ERROR;
        const id = this.cid;

        const extentHTML = ["west", "south", "east", "north"]
          .map(
            (coord) => `
            <label class="${fieldClass}">${LABELS[coord.toUpperCase()]}
              <input name="${coord}" type="number" step="any" aria-describedby="${id}-${ID_SUFFIXES.EXTENT_ERROR}" />
            </label>
          `,
          )
          .join("");

        return `
          <label class="${fieldClass}">${LABELS.PROVIDER}
            <select name="type">
              <option value="${PROVIDER_TYPES.WMTS}">${LABELS.WMTS}</option>
              <option value="${PROVIDER_TYPES.WMS}">${LABELS.WMS}</option>
              <option value="${PROVIDER_TYPES.TMS}">${LABELS.TMS}</option>
              <option value="${PROVIDER_TYPES.ION}">${LABELS.ION}</option>
              <option value="${PROVIDER_TYPES.OSM}">${LABELS.OSM}</option>
              <option value="${PROVIDER_TYPES.BING}">${LABELS.BING}</option>
              <option value="${PROVIDER_TYPES.NATURAL_EARTH}">${LABELS.NATURAL_EARTH}</option>
              <option value="${PROVIDER_TYPES.USGS}">${LABELS.USGS}</option>
            </select>
          </label>

          <section class="${sourceClass}" ${ATTRIBUTES.PROVIDER}="${PROVIDER_TYPES.WMTS}" hidden>
            <label class="${fieldClass}">${LABELS.TILE_URL}
              <textarea name="url" rows="3" aria-describedby="${id}-${ID_SUFFIXES.WMTS_HELP} ${id}-${ID_SUFFIXES.WMTS_ERROR}"></textarea>
              <small id="${id}-${ID_SUFFIXES.WMTS_HELP}">${HELP_TEXT.WMTS_URL}</small>
              <span id="${id}-${ID_SUFFIXES.WMTS_ERROR}" class="${errorClass}"
                ${ATTRIBUTES.ERROR}="cesiumOptions.url" role="alert"></span>
            </label>
            <label class="${fieldClass}">${LABELS.TILING_SCHEME}
              <select name="tilingScheme">
                <option value="">${LABELS.PROVIDER_DEFAULT}</option>
                <option value="${TILING_SCHEMES.GEOGRAPHIC}">${LABELS.GEOGRAPHIC}</option>
                <option value="${TILING_SCHEMES.WEB_MERCATOR}">${LABELS.WEB_MERCATOR}</option>
              </select>
            </label>
            <details>
              <summary>${LABELS.EXTENT}</summary>
               <div class="${extentClass}">${extentHTML}</div>
            </details>
            <p id="${id}-${ID_SUFFIXES.EXTENT_ERROR}" class="${errorClass}"
              ${ATTRIBUTES.ERROR}="cesiumOptions.rectangle" role="alert"></p>
          </section>

          <section class="${sourceClass}" ${ATTRIBUTES.PROVIDER}="${PROVIDER_TYPES.WMS}" hidden>
            <label class="${fieldClass}">${LABELS.SERVICE_URL}
              <input name="url" type="text" inputmode="url" aria-required="true"
                aria-describedby="${id}-${ID_SUFFIXES.WMS_HELP} ${id}-${ID_SUFFIXES.WMS_ERROR}" />
              <small id="${id}-${ID_SUFFIXES.WMS_HELP}">${HELP_TEXT.WMS_URL}</small>
              <span id="${id}-${ID_SUFFIXES.WMS_ERROR}" class="${errorClass}"
                ${ATTRIBUTES.ERROR}="cesiumOptions.url" role="alert"></span>
            </label>
            <label class="${fieldClass}">${LABELS.LAYERS}
              <input name="layers" type="text" aria-required="true"
                aria-describedby="${id}-${ID_SUFFIXES.WMS_LAYERS_HELP} ${id}-${ID_SUFFIXES.WMS_LAYERS_ERROR}" />
              <small id="${id}-${ID_SUFFIXES.WMS_LAYERS_HELP}">${HELP_TEXT.WMS_LAYERS}</small>
              <span id="${id}-${ID_SUFFIXES.WMS_LAYERS_ERROR}" class="${errorClass}"
                ${ATTRIBUTES.ERROR}="cesiumOptions.layers" role="alert"></span>
            </label>
          </section>

          <section class="${sourceClass}" ${ATTRIBUTES.PROVIDER}="${PROVIDER_TYPES.TMS}" hidden>
            <label class="${fieldClass}">${LABELS.TILE_SERVICE_URL}
              <input name="url" type="text" inputmode="url" aria-required="true"
                aria-describedby="${id}-${ID_SUFFIXES.TMS_ERROR}" />
              <span id="${id}-${ID_SUFFIXES.TMS_ERROR}" class="${errorClass}"
                ${ATTRIBUTES.ERROR}="cesiumOptions.url" role="alert"></span>
            </label>
          </section>

          <section class="${sourceClass}" ${ATTRIBUTES.PROVIDER}="${PROVIDER_TYPES.ION}" hidden>
            <label class="${fieldClass}">${LABELS.ION_ASSET_ID}
              <input name="ionAssetId" type="text" inputmode="numeric"
                aria-describedby="${id}-${ID_SUFFIXES.ION_ERROR}" />
              <span id="${id}-${ID_SUFFIXES.ION_ERROR}" class="${errorClass}"
                ${ATTRIBUTES.ERROR}="cesiumOptions.ionAssetId" role="alert"></span>
            </label>
          </section>

          <section class="${sourceClass}" ${ATTRIBUTES.PROVIDER}="${PROVIDER_TYPES.OSM}" hidden>
            <label class="${fieldClass}">${LABELS.TILE_SERVER_URL}
              <input name="url" type="text" inputmode="url"
                aria-describedby="${id}-${ID_SUFFIXES.OSM_ERROR}" />
              <span id="${id}-${ID_SUFFIXES.OSM_ERROR}" class="${errorClass}"
                ${ATTRIBUTES.ERROR}="cesiumOptions.url" role="alert"></span>
            </label>
          </section>

          <section class="${sourceClass}" ${ATTRIBUTES.PROVIDER}="${PROVIDER_TYPES.BING}" hidden>
            <label class="${fieldClass}">${LABELS.SERVICE_URL}
              <input name="url" type="text" inputmode="url"
                aria-describedby="${id}-${ID_SUFFIXES.BING_ERROR}" />
              <span id="${id}-${ID_SUFFIXES.BING_ERROR}" class="${errorClass}"
                ${ATTRIBUTES.ERROR}="cesiumOptions.url" role="alert"></span>
            </label>
            <label class="${fieldClass}">${LABELS.BING_KEY}
              <input name="key" type="password" autocomplete="off"
                aria-describedby="${id}-${ID_SUFFIXES.BING_KEY_HELP}" />
              <small id="${id}-${ID_SUFFIXES.BING_KEY_HELP}">${HELP_TEXT.BING_KEY}</small>
            </label>
          </section>

          <section class="${sourceClass}" ${ATTRIBUTES.PROVIDER}="${PROVIDER_TYPES.NATURAL_EARTH}" hidden>
            <p>${HELP_TEXT.NATURAL_EARTH}</p>
          </section>

          <section class="${sourceClass}" ${ATTRIBUTES.PROVIDER}="${PROVIDER_TYPES.USGS}" hidden>
            <p>${HELP_TEXT.USGS}</p>
          </section>

          <p class="${errorClass}" ${ATTRIBUTES.ERROR}="${GENERAL_ERROR_KEY}" role="alert"></p>
        `;
      },

      /**
       * Fill the source controls from the imagery model.
       * @returns {MapImageryEditorView} This view
       */
      render() {
        this.el.innerHTML = this.template();
        const type = this.model.get("type");
        this.el.querySelector('[name="type"]').value = type;
        this.changeProvider();

        const controls = this.el.querySelector(
          `[${ATTRIBUTES.PROVIDER}="${type}"]`,
        );
        const options = this.model.get("cesiumOptions") || {};
        const url = controls.querySelector('[name="url"]');
        // Keep the default URL from changeProvider when the model has no URL.
        if (url) url.value = options.url || url.value;
        ["ionAssetId", "layers", "key"].forEach((name) => {
          const input = controls.querySelector(`[name="${name}"]`);
          if (input) input.value = options[name] ?? "";
        });
        const tilingScheme = controls.querySelector('[name="tilingScheme"]');
        if (tilingScheme) tilingScheme.value = options.tilingScheme || "";
        if (type === PROVIDER_TYPES.WMTS && options.rectangle) {
          ["west", "south", "east", "north"].forEach((name, index) => {
            controls.querySelector(`[name="${name}"]`).value =
              options.rectangle[index];
          });
        }
        return this;
      },

      /** Show fresh source controls for the selected provider */
      changeProvider() {
        const type = this.el.querySelector('[name="type"]').value;
        this.el
          .querySelectorAll(`[${ATTRIBUTES.PROVIDER}]`)
          .forEach((section) => {
            section.toggleAttribute(
              "hidden",
              section.dataset.provider !== type,
            );
            const inputs = section.querySelectorAll("input, textarea, select");
            for (let index = 0; index < inputs.length; index += 1) {
              inputs[index].value = "";
            }
          });
        this.el.querySelector(
          `[${ATTRIBUTES.PROVIDER}="${PROVIDER_TYPES.OSM}"] [name="url"]`,
        ).value = CesiumImagery.OSM_DEFAULT_URL;
        this.el.querySelector(
          `[${ATTRIBUTES.PROVIDER}="${PROVIDER_TYPES.BING}"] [name="url"]`,
        ).value = CesiumImagery.BING_DEFAULT_URL;
        this.el.querySelector("details").open = false;
        this.el.querySelectorAll(`.${CLASS_NAMES.ERROR}`).forEach((message) => {
          message.replaceChildren();
        });
        this.el.querySelectorAll("[aria-invalid]").forEach((input) => {
          input.removeAttribute("aria-invalid");
        });
      },

      /**
       * Keep source edits in the model, including incomplete settings.
       * Portal Save checks them before writing the config.
       * @param {Event} event The changed field
       */
      updateModel(event) {
        const { name } = event.target;
        const type = this.el.querySelector('[name="type"]').value;
        const controls = this.el.querySelector(
          `[${ATTRIBUTES.PROVIDER}="${type}"]`,
        );
        const cesiumOptions =
          type === this.model.get("type")
            ? { ...this.model.get("cesiumOptions") }
            : {};

        const url = controls.querySelector('[name="url"]');
        if (url) {
          if (url.value) cesiumOptions.url = url.value;
          else delete cesiumOptions.url;
        }

        if (type === PROVIDER_TYPES.ION) {
          cesiumOptions.ionAssetId = controls.querySelector(
            '[name="ionAssetId"]',
          ).value;
        }

        if (type === PROVIDER_TYPES.WMS) {
          cesiumOptions.layers =
            controls.querySelector('[name="layers"]').value;
        }

        if (type === PROVIDER_TYPES.BING) {
          const key = controls.querySelector('[name="key"]').value;
          if (key) cesiumOptions.key = key;
          else delete cesiumOptions.key;
        }

        if (type === PROVIDER_TYPES.WMTS) {
          const scheme = controls.querySelector('[name="tilingScheme"]').value;
          if (scheme) cesiumOptions.tilingScheme = scheme;
          else delete cesiumOptions.tilingScheme;

          const rectangle = ["west", "south", "east", "north"].map(
            (coordinate) =>
              controls.querySelector(`[name="${coordinate}"]`).value,
          );
          if (rectangle.every((coordinate) => coordinate === "")) {
            delete cesiumOptions.rectangle;
          } else {
            cesiumOptions.rectangle = rectangle.map((coordinate) =>
              coordinate === "" ? null : Number(coordinate),
            );
          }
        }

        this.model.set({ type, cesiumOptions });
        if (["url", "ionAssetId", "layers"].includes(name)) {
          this.showValidation(`cesiumOptions.${name}`);
        }
      },

      /**
       * Validate coordinates once focus leaves the extent group.
       * @param {FocusEvent} event The coordinate focus change
       */
      validateExtent(event) {
        if (event.currentTarget.contains(event.relatedTarget)) return;
        this.showValidation("cesiumOptions.rectangle");
      },

      /**
       * Show source errors for one field, or all fields on Save.
       * @param {string} [field] The source field to check
       */
      showValidation(field) {
        const errors = this.model.validate(this.model.attributes) || {};
        const controls = this.el.querySelector(
          `[${ATTRIBUTES.PROVIDER}="${this.model.get("type")}"]`,
        );
        this.el.querySelectorAll(`.${CLASS_NAMES.ERROR}`).forEach((message) => {
          if (field && message.dataset.error !== field) return;
          message.replaceChildren();
          this.el
            .querySelectorAll(`[aria-describedby~="${message.id}"]`)
            .forEach((input) => {
              input.removeAttribute("aria-invalid");
            });
        });
        Object.entries(errors).forEach(([name, message]) => {
          if (field && name !== field) return;
          const error =
            controls.querySelector(`[${ATTRIBUTES.ERROR}="${name}"]`) ||
            this.el.querySelector(
              `[${ATTRIBUTES.ERROR}="${GENERAL_ERROR_KEY}"]`,
            );
          error.textContent = message;
          controls
            .querySelectorAll(`[aria-describedby~="${error.id}"]`)
            .forEach((input) => {
              input.setAttribute("aria-invalid", "true");
            });
        });
        if (!field) {
          const input = this.el.querySelector('[aria-invalid="true"]');
          if (input) {
            const details = input.closest("details");
            if (details) details.open = true;
            input.focus();
          }
        }
      },
    },
  );

  /**
   * Imagery provider types this editor can display.
   * @type {string[]}
   */
  MapImageryEditorView.SUPPORTED_TYPES = Object.values(PROVIDER_TYPES);

  return MapImageryEditorView;
});