Source: src/js/views/maps/FeatureInfoView.js


'use strict';

define(
  [
    'jquery',
    'underscore',
    'backbone',
    'models/maps/Feature',
    'text!templates/maps/feature-info/feature-info.html'
  ],
  function (
    $,
    _,
    Backbone,
    Feature,
    Template
  ) {

    /**
    * @class FeatureInfoView
    * @classdesc An info-box / panel that shows more details about a specific geo-spatial
    * feature that is highlighted or in focus in a Map View, as specified by a given
    * {@link Feature} model. The format and content of the info-box varies based on which
    * template is configured in the parent {@link MapAsset} model, but at a minimum a link
    * is included that opens the associated {@link LayerInfoView}. Unless otherwise
    * configured, the title of the panel will use the value of the feature's 'name',
    * 'title', 'id', 'identifier', or 'assetId' property, if it has one (case
    * insensitive).
    * @classcategory Views/Maps
    * @name FeatureInfoView
    * @extends Backbone.View
    * @screenshot views/maps/FeatureInfoView.png
    * @since 2.18.0
    * @constructs
    */
    var FeatureInfoView = Backbone.View.extend(
      /** @lends FeatureInfoView.prototype */{

        /**
        * The type of View this is
        * @type {string}
        */
        type: 'FeatureInfoView',

        /**
        * The HTML classes to use for this view's element
        * @type {string}
        */
        className: 'feature-info',

        /**
        * The model that this view uses
        * @type {Feature}
        */
        model: undefined,

        /**
         * The primary HTML template for this view
         * @type {Underscore.template}
         */
        template: _.template(Template),

        /**
         * A ContentTemplate object specifies a single template designed to render
         * information about the Feature.
         * @typedef {Object} ContentTemplate
         * @since 2.19.0
         * @property {string} [name] - An identifier for this template.
         * @property {string[]} [options] - The list of keys (option names) that are
         * allowed for the given template. Only options with these keys will be passed to
         * the underscore.js template, regardless of what is configured in the
         * {@link MapConfig#FeatureTemplate}. When no options are specified, then the
         * entire Feature model will be passed to the template as JSON.
         * @property {string} template - The path to the HTML template. This will be used
         * with require() to load the template as needed.
         */

        /**
         * The list of available templates that format information about the Feature. The
         * last template in the list is the default template. It will be used when a
         * matching template is not found or one is not specified.
         * @type {ContentTemplate[]}
         * @since 2.19.0
         */
        contentTemplates: [
          {
            name: 'story',
            template: 'text!templates/maps/feature-info/story.html',
            options: ['title', 'subtitle', 'description', 'thumbnail', 'url', 'urlText']
          },
          {
            name: 'table',
            template: 'text!templates/maps/feature-info/table.html'
          }
        ],

        /**
        * Creates an object that gives the events this view will listen to and the
        * associated function to call. Each entry in the object has the format 'event
        * selector': 'function'.
        * @returns {Object}
        */
        events: function () {
          var events = {};
          // Close the layer details panel when the toggle button is clicked. Get the
          // class of the toggle button from the classes property set in this view.
          events['click .' + this.classes.toggle] = 'close'
          // Open the Layer Details panel
          events['click .' + this.classes.layerDetailsButton] = 'showLayerDetails'
          events['click .' + this.classes.zoomButton] = 'zoomToFeature'
          return events
        },

        /**
         * Classes that are used to identify the HTML elements that comprise this view.
         * @type {Object}
         * @property {string} open The class to add to the outermost HTML element for this
         * view when the layer details view is open/expanded (not hidden)
         * @property {string} toggle The element in the template that acts as a toggle to
         * close/hide the info view
         * @property {string} layerDetailsButton The layer details button is added to the
         * view when the selected feature is associated with a layer (a MapAsset like a 3D
         * tileset). When clicked, it opens the LayerDetailsView for that layer.
         * @property {string} contentContainer The iframe that holds the content rendered
         * by the {@link FeatureInfoView#ContentTemplate}
         * @property {string} title The label/title at the very top of the Feature panel,
         * next to the close button.
         */
        classes: {
          open: 'feature-info--open',
          toggle: 'feature-info__toggle',
          layerDetailsButton: 'feature-info__layer-details-button',
          zoomButton: 'feature-info__zoom-button',
          contentContainer: 'feature-info__content',
          title: 'feature-info__label'
        },

        /**
         * Whether or not the layer details view is open
         * @type {Boolean}
         */
        isOpen: false,

        /**
        * Executed when a new FeatureInfoView is created
        * @param {Object} [options] - A literal object with options to pass to the view
        */
        initialize: function (options) {

          try {
            // Get all the options and apply them to this view
            if (typeof options == 'object') {
              for (const [key, value] of Object.entries(options)) {
                this[key] = value;
              }
            }
          } catch (e) {
            console.log('A FeatureInfoView failed to initialize. Error message: ' + e);
          }

        },

        /**
        * Renders this view
        * @return {FeatureInfoView} Returns the rendered view element
        */
        render: function () {

          try {

            const view = this
            const classes = view.classes

            // Show the feature info box as open if the view is set to have it open
            // already
            if (view.isOpen) {
              view.el.classList.add(view.classes.open);
            }

            // Insert the principal template into the view
            view.$el.html(view.template({
              classes: classes
            }));

            const iFrame = view.el
              .querySelector('.' + classes.contentContainer);

            // Select the iFrame
            const iFrameDoc = iFrame
              .contentWindow
              .document

            // Add a script that gets all of the CSS stylesheets from the parent and
            // applies them within the iFrame. Create a div within the iFrame to hold the
            // feature info template content.
            iFrameDoc.open();
            iFrameDoc.write(`
              <div id="content"></div>
              <script type="text/javascript">
              window.onload = function() {
                  if (parent) {
                      var h = document.getElementsByTagName("head")[0];
                      var ss = parent.document.getElementsByTagName("style");
                      for (var i = 0; i < ss.length; i++)
                          h.appendChild(ss[i].cloneNode(true));
                  }
              }
              </script>
              <style>
                body {
                  background-color: transparent;
                  color: var(--map-col-text-body, var(--map-col-text__deprecate));
                  font-family: "Helvetica Nueue", "Helvetica", "Arial", "Lato", "sans serif";
                  margin: 0;
                  box-sizing: border-box;
                }
              </style>
            `);
            iFrameDoc.close();

            // Identify the elements from the template that will be updated when the
            // Feature model changes
            view.elements = {
              title: view.el.querySelector('.' + classes.title),
              iFrame: iFrame,
              iFrameContentContainer: iFrameDoc.getElementById('content'),
              layerDetailsButton: view.el.querySelector('.' + classes.layerDetailsButton),
              zoomButton: view.el.querySelector('.' + classes.zoomButton),
            }

            view.update();

            // Ensure the view's main element has the given class name
            view.el.classList.add(view.className);

            // When the model changes, update the view
            view.stopListening(view.model, 'change')
            view.listenTo(view.model, 'change', view.update)

            return view

          }
          catch (error) {
            console.log(
              'There was an error rendering a FeatureInfoView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Updates the view with information from the current Feature model
         */
        updateContent: function () {

          try {

            const view = this;

            // Elements to update
            const title = this.getFeatureTitle()
            const iFrame = this.elements.iFrame
            const iFrameDiv = this.elements.iFrameContentContainer
            const layerDetailsButton = this.elements.layerDetailsButton
            const zoomButton = this.elements.zoomButton
            const mapAsset = this.model.get('mapAsset')
            let mapAssetLabel = mapAsset ? mapAsset.get('label') : null
            const layerButtonDisplay = mapAsset ? null : 'none'
            const layerButtonText = 'See ' + mapAssetLabel + ' layer details'
            // The Cesium Map Widget can't zoom to Cesium3DTileFeatures, so for now, hide
            // the 'zoom to feature' button
            const zoomButtonDisplay =
              (!mapAsset || mapAsset.get('type') === 'Cesium3DTileset') ? 'none' : null;

            // Insert the title into the title element
            this.elements.title.innerHTML = title

            // Update the iFrame content
            this.getContent().then(function (html) {
              iFrameDiv.innerHTML = html;
              view.updateIFrameHeight();
              // Not the ideal solution, but check the height of the iFrame
              // again after some time to allow external content to load. This
              // is necessary for content that loads asynchronously, like
              // images. Difficult to set listeners for this, since the content
              // may be from a different domain.
              setTimeout(function () {
                view.updateIFrameHeight();
              }, 850);
            })

            // Show or hide the layer details button, update the text
            layerDetailsButton.style.display = layerButtonDisplay
            layerDetailsButton.innerText = layerButtonText

            // Show or hide the zoom to feature button
            zoomButton.style.display = zoomButtonDisplay

          }
          catch (error) {
            console.log(
              'There was an error rendering the content of a FeatureInfoView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Update the height of the iFrame to match the height of the content
         * within it.
         * @param {number} [height] The height to set the iFrame to. If no
         * height is provided, then the height of the content within the iFrame
         * will be used.
         * @param {boolean} [limit=true] Whether or not to limit the height of
         * the iFrame to the height of the window, minus 275px.
         * @since 2.27.0
         */
        updateIFrameHeight: function (height, limit = true) {
          const iFrame = this.elements?.iFrame;
          if (!iFrame) return;
          if ((!height && height !== 0) || height < 0) {
            height = iFrame.contentWindow.document.body.scrollHeight + 5;
          }
          if (limit) {
            const maxHeight = window.innerHeight - 275;
            height = height > maxHeight ? maxHeight : height;
          }
          iFrame.style.height = height + "px";
        },

        /**
         * Get the inner HTML content to insert into the iFrame. The content will vary
         * based on the feature and if there is a template set on the parent Map Asset
         * model.
         * @since 2.19.0
         * @returns {Promise|null} Returns a promise that resolves to the content HTML
         * when ready, otherwise null
         */
        getContent: function () {
          try {

            let content = null;
            let templateOptions = this.model.toJSON();
            const mapAsset = this.model.get('mapAsset')
            const featureProperties = this.model.get('properties')
            const templateConfig = mapAsset ? mapAsset.get('featureTemplate') : null
            const propertyMap = templateConfig ? templateConfig.options : {}
            const templateName = templateConfig ? templateConfig.template : null;
            const contentTemplates = this.contentTemplates;

            // Given the name of a template configured in the MapAsset model, find the
            // matching template from the contentTemplates set on this view
            let contentTemplate = contentTemplates.find(
              template => template.name == templateName
            );
            if (!contentTemplate) {
              contentTemplate = contentTemplates[contentTemplates.length - 1];
            }

            // To get variables to pass to the template, there must be properties set on
            // the feature and the selected content template must accept options
            if (
              contentTemplate && contentTemplate.options &&
              templateConfig && templateConfig.options
            ) {
              templateOptions = {}
              contentTemplate.options.forEach(function (prop) {
                const key = propertyMap[prop]
                templateOptions[prop] = featureProperties[key] || ''
              })
            }

            // Return a promise that resolves to the content HTML
            return new Promise(function (resolve, reject) {
              if (contentTemplate) {
                require([contentTemplate.template], function (template) {
                  content = _.template(template)(templateOptions);
                  resolve(content);
                })
              } else {
                resolve(null);
              }
            })

          }
          catch (error) {
            console.log(
              'There was an error getting the content of a FeatureInfoView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Create a title for the feature info box
         * @since 2.19.0
         * @returns {string} The title for the feature info box
         */
        getFeatureTitle: function () {
          try {
            let title = '';
            let suffix = '';

            if (this.model) {

              // Get the layer/mapAsset model
              const mapAsset = this.model.get('mapAsset')

              const featureTemplate = mapAsset ? mapAsset.get('featureTemplate') : null;
              const properties = this.model.get('properties') ?? {};
              const assetName = mapAsset ? mapAsset.get('label') : null;
              let name = featureTemplate ? properties[featureTemplate.label] : this.model.get('label');

              // Build a title if the feature has no label. Check if the feature has a name,
              // title, ID, or identifier property. Search for these properties independent
              // of case. If none of these properties exist, use the feature ID provided by
              // the model.
              if (!name) {

                title = 'Feature';

                let searchKeys = ['name', 'title', 'id', 'identifier']
                searchKeys = searchKeys.map(key => key.toLowerCase());
                const propKeys = Object.keys(properties)
                const propKeysLower = propKeys.map(key => key.toLowerCase());

                // Search by search key, since search keys are in order of preference. Find
                // the first matching key.
                const nameKeyLower = searchKeys.find(function (searchKey) {
                  return propKeysLower.includes(searchKey)
                });

                // Then figure out which of the original property keys matches (we need it
                // in the original case).
                const nameKey = propKeys[propKeysLower.indexOf(nameKeyLower)]

                name = properties[nameKey] ?? this.model.get('featureID');

                if (assetName) {
                  suffix = ' from ' + assetName + ' Layer'
                }

              }
              if (name) {
                title = title + ' ' + name
              }
              if (suffix) {
                title = title + suffix
              }

            }

            // Do some basic sanitization of the title
            title = title.replace(/&/g, '&amp;')
            title = title.replace(/</g, '&lt;')
            title = title.replace(/>/g, '&gt;')
            title = title.replace(/"/g, '&quot;')
            title = title.replace(/'/g, '&#039;')

            return title
          }
          catch (error) {
            console.log(
              'There was an error making a title for the FeatureInfoView' +
              '. Error details: ' + error
            );
            return 'Feature'
          }
        },

        /**
         * Show details about the layer that contains this feature. The function does this
         * by setting the associated layer model's 'selected' attribute to true. The
         * parent Map view has a listener set to show the Layer Details view when this
         * attribute is changed.
         */
        showLayerDetails: function () {
          try {
            if (this.model && this.model.get('mapAsset')) {
              this.model.get('mapAsset').set('selected', true)
            }
          }
          catch (error) {
            console.log(
              'There was an error showing the layer details panel from a FeatureInfoView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Trigger an event from the parent Map model that tells the Map Widget to
         * zoom to the full extent of this feature in the map. Also make sure that the Map
         * Asset layer is visible in the map.
         */
        zoomToFeature: function () {
          try {
            const model = this.model;
            const mapAsset = model ? model.get('mapAsset') : false;
            if (mapAsset) {
              mapAsset.zoomTo(model)
            }
          }
          catch (error) {
            console.log(
              'There was an error zooming to a feature from a FeatureInfoView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Shows the feature info box
         */
        open: function () {
          try {
            this.el.classList.add(this.classes.open);
            this.isOpen = true;
          }
          catch (error) {
            console.log(
              'There was an error showing the FeatureInfoView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Hide the feature info box from view
         */
        close: function () {
          try {
            this.el.classList.remove(this.classes.open);
            this.isOpen = false;
            // When the feature info panel is closed, remove the Feature model from the
            // Features collection. This will trigger the map widget to remove
            // highlighting from the feature.
            if (this.model && this.model.collection) {
              this.model.collection.remove(this.model);
            }
          }
          catch (error) {
            console.log(
              'There was an error hiding the FeatureInfoView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Update the content that's displayed in a feature info box, based on the
         * information in the Feature model. Open the panel if there is a Feature model,
         * or close it if there is no model or the model has only default values.
         */
        update: function () {
          try {
            if (!this.model || this.model.isDefault()) {
              if (this.isOpen) {
                this.close()
              }
            } else {
              this.open()
              this.updateContent()
            }
          }
          catch (error) {
            console.log(
              'There was an error updating the content of a FeatureInfoView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Stops listening to the previously set model, replaces it with a new Feature
         * model, re-sets the listeners and re-renders the content in this view based on
         * the new model.
         * @param {Feature} newModel The new Feature model to display content for
         */
        changeModel: function (newModel) {

          const view = this
          const currentModel = view.model
          const currentMapAsset = currentModel ? currentModel.get('mapAsset') : null;
          
          // Stop listening to the current Feature & Map Asset models before they're removed
          view.stopListening(currentModel, 'change')
          if (currentMapAsset) {
            view.stopListening(currentMapAsset, 'change:visible')
          }

          // Update the model
          view.model = newModel
          // Listen to the new model
          view.stopListening(newModel, 'change')
          view.listenTo(newModel, 'change', view.update)

          // If the Map Asset layer is ever hidden, then de-select the Feature and close
          // the view view
          const newMapAsset = newModel ? newModel.get('mapAsset') : null;
          if (newMapAsset) {
            view.listenTo(newMapAsset, 'change:visible', function () {
              if (!newMapAsset.get('visible')) {
                view.close()
              }
            })
          }

          // Update
          view.update()
        }

      }
    );

    return FeatureInfoView;

  }
);